diff --git a/.condarc b/.condarc new file mode 100644 index 0000000..03dd10b --- /dev/null +++ b/.condarc @@ -0,0 +1,5 @@ +# Use only conda-forge for this project, without changing the user's global config. +channels: #!final + - conda-forge +default_channels: [] #!final +channel_priority: strict diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f3fd18b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: + - main + - feature/experiments-cli + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python 3.10 tests + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install project dependencies + run: | + python -m pip install --upgrade pip tomli + python - <<'PY' + import subprocess + import sys + import tomli + + with open("pyproject.toml", "rb") as source: + dependencies = tomli.load(source)["project"]["dependencies"] + + subprocess.check_call([ + sys.executable, + "-m", + "pip", + "install", + "--extra-index-url", + "https://download.pytorch.org/whl/cpu", + *dependencies, + ]) + PY + + - name: Run Python tests + run: python -m unittest discover -s tests -v + + frontend: + name: Frontend build + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Type-check and build frontend + run: npm run build diff --git a/.github/workflows/models.yml b/.github/workflows/models.yml new file mode 100644 index 0000000..e1e7a78 --- /dev/null +++ b/.github/workflows/models.yml @@ -0,0 +1,54 @@ +name: Pretrained model compatibility + +# Real downloads and full captures are opt-in; ordinary PR tests remain offline. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + checkpoint: + name: ${{ matrix.checkpoint }} / ${{ matrix.mode }} + runs-on: ubuntu-22.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + checkpoint: [vit-tiny, tinyclip] + mode: [cli, web, playground] + env: + HF_HUB_DISABLE_IMPLICIT_TOKEN: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + - name: Install pinned compiler and model dependencies + run: | + python -m pip install --upgrade pip tomli + python - <<'PY' + import subprocess, sys, tomli + with open('pyproject.toml', 'rb') as source: + dependencies = tomli.load(source)['project']['dependencies'] + subprocess.check_call([sys.executable, '-m', 'pip', 'install', + '--extra-index-url', 'https://download.pytorch.org/whl/cpu', *dependencies]) + PY + - name: Validate pinned checkpoint + env: + HF_HOME: ${{ runner.temp }}/huggingface + run: >- + python scripts/validate_hf_models.py ${{ matrix.checkpoint }} + --mode ${{ matrix.mode }} --out build/model-validation --timeout 900 + - name: Upload results, not weights or large IR dumps + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.checkpoint }}-${{ matrix.mode }} + path: | + build/model-validation/validation.json + build/model-validation/*.log + build/model-validation/**/verification.json + retention-days: 7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7c5d48a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,179 @@ +name: Publish to PyPI + +on: + push: + branches: + - main + - feature/experiments-cli + tags: + - "v*" + pull_request: + +permissions: + contents: read + +jobs: + build: + name: Build and verify Linux wheel + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Python build tools + run: | + python -m pip install --upgrade pip "setuptools>=77" wheel twine tomli \ + "auditwheel==6.8.2" "patchelf==0.19.1.0" + + - name: Verify wheel repair tools + run: | + python - <<'PY' + import importlib.metadata + import shutil + import subprocess + import sysconfig + from pathlib import Path + + for name, version in [('auditwheel', '6.8.2'), ('patchelf', '0.19.1.0')]: + actual = importlib.metadata.version(name) + if actual != version: + raise SystemExit(f'{name}: expected {version}, found {actual}') + selected = shutil.which('patchelf') + expected = Path(sysconfig.get_path('scripts')) / 'patchelf' + if selected is None or Path(selected).resolve() != expected.resolve(): + raise SystemExit(f'Wrong patchelf on PATH: {selected}; expected {expected}') + subprocess.run([selected, '--version'], check=True) + subprocess.run(['auditwheel', '--version'], check=True) + PY + + - name: Check component versions and release tag + env: + RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }} + run: | + python - <<'PY' + import os + import re + import tomli + from pathlib import Path + + with open("pyproject.toml", "rb") as source: + version = tomli.load(source)["project"]["version"] + + tag = os.environ["RELEASE_TAG"] + if tag and not re.fullmatch(r"v\d+\.\d+\.\d+", tag): + raise SystemExit(f"Release tag must have the form vX.Y.Z, got {tag!r}") + if tag and tag != f"v{version}": + raise SystemExit(f"Tag {tag!r} does not match project version {version!r}") + for name, pattern in { + 'compilerlens/__init__.py': r'__version__ = "([^"]+)"', + 'llvm/CMakeLists.txt': r'project\(CompilerLensLLVM VERSION (\S+)', + 'llvm/tools/main.cpp': r'compilerlens-native ([\d.]+) LLVM', + 'llvm/lib/Plugin.cpp': r'"CompilerLensPasses", "([^"]+)"', + 'llvm/lib/Lineage.cpp': r'"pass_version", "([^"]+)"', + }.items(): + match = re.search(pattern, Path(name).read_text()) + if not match or match[1] != version: + raise SystemExit(f'{name} version does not match {version}') + PY + + - name: Install LLVM 22 and build tools + run: | + wget --quiet https://apt.llvm.org/llvm.sh + chmod +x llvm.sh + sudo ./llvm.sh 22 + sudo apt-get update + sudo apt-get install --yes llvm-22-dev libzstd-dev zlib1g-dev + npm ci --prefix frontend + + - name: Install project dependencies and run Python tests + run: | + python - <<'PY' + import subprocess + import sys + import tomli + + with open("pyproject.toml", "rb") as source: + dependencies = tomli.load(source)["project"]["dependencies"] + + subprocess.check_call([ + sys.executable, + "-m", + "pip", + "install", + "--extra-index-url", + "https://download.pytorch.org/whl/cpu", + *dependencies, + ]) + PY + python -m unittest discover -s tests -v + + - name: Build and test release wheel + run: | + python scripts/build_release.py --llvm-dir /usr/lib/llvm-22/lib/cmake/llvm + python -m unittest discover -s tests -p test_source_artifacts.py -v + auditwheel repair dist/compilerlens-*.whl --wheel-dir wheelhouse + python -m twine check wheelhouse/*.whl + auditwheel show wheelhouse/*.whl + + - name: Smoke-test installed wheel + env: + PYTHONPATH: "" + COMPILERLENS_NATIVE: "" + OMP_NUM_THREADS: "2" + run: | + CLENS_CHECK_DIR="$(mktemp -d "$RUNNER_TEMP/compilerlens-wheel.XXXXXX")" + python -m venv "$CLENS_CHECK_DIR/venv" + "$CLENS_CHECK_DIR/venv/bin/python" -m pip install \ + --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu wheelhouse/*.whl + export PATH="$CLENS_CHECK_DIR/venv/bin:$PATH" + export COMPILERLENS_WORKSPACE="$CLENS_CHECK_DIR/workspace" + cd "$CLENS_CHECK_DIR" + python -m pip check + compilerlens --version + compilerlens doctor + compilerlens compile --example matmul --out runs/matmul --lineage required + compilerlens compile --example tiny_vit --out runs/tiny-vit --lineage required + compilerlens compile --example tiny_clip --out runs/tiny-clip --lineage required + python "$GITHUB_WORKSPACE/scripts/check_installed.py" \ + "$CLENS_CHECK_DIR/venv/bin/python" "$CLENS_CHECK_DIR/runs/matmul" + + - uses: actions/upload-artifact@v4 + with: + name: pypi-wheel + path: wheelhouse/*.whl + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish trusted release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-22.04 + environment: + name: pypi + url: https://pypi.org/project/compilerlens/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: pypi-wheel + path: wheelhouse + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: wheelhouse/ diff --git a/.gitignore b/.gitignore index e2b9eac..390737d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ # Build output node_modules/ dist/ +wheelhouse/ *.tsbuildinfo # Generated artifacts -- rebuild with `npm run artifact` (see README) frontend/public/artifacts/ +examples/*/native/ +examples/*/lineage.json # Verification output, regenerable via `npm run verify` frontend/screenshots/ @@ -24,3 +27,12 @@ __pycache__/ .DS_Store .idea/ .vscode/ + +# CompilerLens release products and local runs +build/ +*.egg-info/ +compilerlens/_bin/ +compilerlens/_web/ +compilerlens-runs/ +runs/ +jobs/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..eb63c29 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +include README.md pyproject.toml setup.py +recursive-include llvm *.cpp *.h *.ll *.py *.txt *.md *.cmake *.patch +recursive-include scripts *.py *.mjs +recursive-include scripts *.json +recursive-include frontend src/* +include frontend/package.json frontend/package-lock.json frontend/index.html frontend/vite.config.ts frontend/vite.release.config.ts frontend/tsconfig.json +recursive-include frontend/src *.tsx *.ts *.css +recursive-include compilerlens/_bin * +recursive-include compilerlens/_web * +global-exclude __pycache__ *.pyc diff --git a/README.md b/README.md index fe238cb..e36da9d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # CompilerLens +

+ PyPI version + Tested with Python 3.10 + Platform: Linux x86-64 + License: Apache 2.0 +

+ CompilerLens is an interactive explorer for AI compiler pipelines. It compiles a PyTorch or Hugging Face model through IREE and presents the resulting Torch, MLIR, LLVM IR, and x86-64 stages in one navigable interface. @@ -15,11 +22,12 @@ stages in one navigable interface. - [Key Capabilities](#key-capabilities) - [System Architecture and Repository Layout](#system-architecture-and-repository-layout) - [System Requirements](#system-requirements) -- [Installation](#installation) +- [CLI Quick Start](#cli-quick-start) +- [Installation from Source](#installation-from-source) - [Running CompilerLens Locally](#running-compilerlens-locally) - [Compiling Models from the Web Interface](#compiling-models-from-the-web-interface) - [Supported Model Architectures](#supported-model-architectures) -- [Command-Line Model Compilation](#command-line-model-compilation) +- [Repository Compilation Script](#repository-compilation-script) - [Compiler Playground and Benchmarking](#compiler-playground-and-benchmarking) - [Validation and Testing](#validation-and-testing) - [Troubleshooting](#troubleshooting) @@ -33,9 +41,11 @@ stages in one navigable interface. - An interactive model architecture map linked to the operations each module exports - Side-by-side textual and semantic diffs - Compiler evidence linked directly to the IR that produced it -- Metadata-backed operation lineage from framework-level operations through LLVM IR +- Compiler-recorded operation lineage from framework-level operations through LLVM IR and assembly - A landing-page search flow that compiles a Hugging Face model and adds it as a workload - A Compiler Playground for changing selected compiler options and benchmarking the result +- An installable CLI for capturing, inspecting, tracing, comparing, and benchmarking saved runs +- A bundled browser viewer for opening CLI captures without a separate frontend setup ## System Architecture and Repository Layout @@ -48,48 +58,58 @@ the TypeScript frontend. ```mermaid %%{init: {"theme":"base","themeVariables":{"background":"#080c12","primaryTextColor":"#e5e7eb","lineColor":"#64748b","fontFamily":"ui-monospace, SFMono-Regular, Menlo, monospace","clusterBkg":"#0b111b","clusterBorder":"#334155","edgeLabelBackground":"#111827"},"flowchart":{"curve":"linear","nodeSpacing":36,"rankSpacing":48}}}%% flowchart TB - subgraph Entry["1 · User entry points"] + subgraph Entry["1 · CLI and web entry points"] direction LR - Search["Landing page
Search and compile a model"] - Controls["Compiler Playground
Choose model, stage, and flags"] + CLI["CLI · compile
HF model · packaged example · Python factory"] + Import["CLI · import
Existing compiler dumps"] + Search["Web · Model search
Compile and explore a Hugging Face model"] + Controls["Web · Compiler Playground
Choose model, stage, and flags"] end - subgraph Service["2 · FastAPI orchestration"] + subgraph Service["2 · Shared services and web orchestration"] direction LR - Explore["Persistent exploration job
POST /explore · poll GET /compile/{id}"] - Compile["Temporary compilation job
POST /compile · poll GET /compile/{id}"] + Explore["FastAPI · Explore job
POST /explore"] + Shared["Shared capture service
Parameters · progress · run manifest"] + Compile["FastAPI · Playground job
POST /compile"] end - subgraph Preparation["3 · Shared model preparation and export"] + subgraph Preparation["3 · Model preparation and export"] direction TB - Resolve["Hugging Face Hub or local cache
Resolve revision and architecture"] - Wrap["PyTorch model adapter
Load float32 weights · wrap output · build inputs"] + Resolve["Hugging Face Hub or cache
Resolve revision · load model · build inputs"] + Local["Packaged example or Python factory
Build module and example tensors"] Export["PyTorch export + IREE Turbine AOT
Capture module ownership · emit Torch MLIR"] - Mode{"Compilation mode"} + Mode{"Capture or Playground?"} - Resolve --> Wrap --> Export --> Mode + Resolve --> Export + Local --> Export + Export --> Mode end - subgraph Compilation["4 · Compilation, storage, and visualization"] + subgraph Compilation["4 · IREE compilation, analysis, and results"] direction LR - subgraph Persistent["Persistent exploration path"] + subgraph Persistent["Saved capture · CLI and web Explore"] direction TB - Capture["Full IREE pipeline capture
iree-compile + iree-opt"] - Dumps[("Compiler dump directory
examples/{model}/
MLIR · pass logs · LLVM IR · assembly")] - Analyze["Artifact construction
Parse · normalize · architecture · lineage · diff"] - Artifacts[("Static artifact store
frontend/public/artifacts/
model JSON · index.json")] - Explorer["Exploration interface
Architecture map · Pipeline · Lineage"] - - Capture --> Dumps --> Analyze --> Artifacts --> Explorer + Capture["IREE pipeline capture
iree-compile + iree-opt · debug information"] + Dumps[("Captured compiler files
MLIR · pass logs · LLVM IR · assembly · objects")] + Analyze["LLVM analysis + Python artifact construction
Instruction reports · source joins · architecture · diffs"] + Run[("Self-contained run
Artifacts · lineage · saved tensors · versions and hashes")] + Terminal["CLI · inspect / trace / diff
Terminal tables, IR, and JSON"] + SavedBenchmark["CLI · bench
IREE runtime + saved input tensors"] + Explorer["Browser · CLI view or web library
Architecture · Pipeline · Operation Lineage"] + + Capture --> Dumps --> Analyze --> Run + Run --> Terminal + Run -->|view or web publication| Explorer + Run -.-> SavedBenchmark -.-> Terminal end - subgraph TemporaryPath["Compiler Playground path"] + subgraph TemporaryPath["Compiler Playground · selected stages"] direction TB - Selected["Focused IREE compilation
Compile only the requested stage"] - Temporary[("Temporary work directory
Selected MLIR + executable VMFB")] + Selected["Focused IREE compilation
Selected IR stage + executable VMFB"] + Temporary[("Job files · jobs/job-id/
Selected IR · VMFB · benchmark inputs")] JobState[("In-memory job state
Status · signals · output paths")] - Benchmark["Optional runtime benchmark
iree-benchmark-module"] + Benchmark["Optional web benchmark
iree-benchmark-module"] Results["Playground interface
Generated IR · signals · timing"] Selected --> Temporary --> JobState --> Results @@ -97,19 +117,22 @@ flowchart TB end end - Search --> Explore + CLI --> Shared + Search --> Explore --> Shared Controls --> Compile - Explore --> Resolve + Shared --> Resolve + Shared --> Local Compile --> Resolve - Mode -->|Full pipeline| Capture - Mode -->|Selected stage| Selected - - class Search,Controls,Explorer,Results ui - class Explore,Compile api - class Resolve,Wrap model - class Export,Capture,Selected,Benchmark compiler + Import -->|copy existing dumps into a new run| Dumps + Mode -->|CLI or web Explore| Capture + Mode -->|Web Playground| Selected + + class CLI,Import,Search,Controls,Terminal,Explorer,Results ui + class Explore,Shared,Compile api + class Resolve,Local model + class Export,Capture,Selected,Benchmark,SavedBenchmark compiler class Mode decision - class Dumps,Artifacts persistent + class Dumps,Run persistent class Temporary,JobState transient class Analyze analysis @@ -130,33 +153,34 @@ flowchart TB style TemporaryPath fill:#1d180b,stroke:#735d16,stroke-width:1px,color:#fde68a ``` -Blue nodes are browser surfaces, violet nodes are API orchestration, orange nodes are shared -model preparation, pink nodes are compiler execution, green nodes are durable files, and amber -nodes are temporary state. Both API routes converge on the same model preparation code before -branching into either complete artifact capture or fast selected-stage compilation. - -The persistent landing-page flow performs the following steps: - -1. `models/detect.py` resolves the Hugging Face revision and determines the supported model - signature. -2. `models/hf_wrapper.py` loads the model, normalizes its inputs, and exposes a traceable tensor - output. -3. `backend/compiler/runner.py` exports the PyTorch module once, records exact module ownership - from `torch.export` metadata, and invokes `iree-compile` and `iree-opt` to capture the - lowering pipeline. -4. The raw stage, pass, LLVM IR, and assembly dumps are written under `examples//` for - persisted compilations. -5. `ingest/` parses those dumps and joins model modules to Torch operations, stage metadata, - diffs, compiler evidence, and operation lineage. -6. The resulting artifact and workload index are written to `frontend/public/artifacts/`. -7. The React frontend first renders the model architecture, then lets each mapped module open - its compiler lineage or the complete Monaco and Golden Layout workspace. - -The Compiler Playground deliberately stops short of artifact construction. It exports the same -wrapped model, compiles only the requested stage into a temporary directory, retains a VMFB for -optional benchmarking, and exposes status, signals, IR paths, and timing through in-memory job -state. These Playground jobs disappear when the API process restarts; persisted exploration -artifacts do not. +Blue nodes are user-facing CLI and browser surfaces; violet nodes coordinate work; +orange nodes prepare models; pink nodes execute compiler or runtime tools; teal nodes +analyze compiler output; green nodes store saved captures; amber nodes hold Playground +files and job state. Solid arrows show the main flow; dashed arrows show optional benchmarking. + +The CLI and web model search use the same capture service: + +1. Choose a Hugging Face model, or use a packaged example or local Python factory through the CLI. +2. Prepare the model and actual input tensors, resolve the model revision, and export through + PyTorch and IREE Turbine while capturing model-module ownership. +3. Run IREE and retain MLIR stages, pass logs, LLVM snapshots, assembly, and object files. +4. Analyze LLVM snapshots with the C++ pass and join compiler source locations in Python. + Build architecture mappings, operation lineage, diffs, and the browser artifact. See the + [LLVM component guide](llvm/README.md) for the analysis details and coverage limits. +5. Save a self-contained run. CLI captures use `--out` or a fresh directory under + `compilerlens-runs/`; web Explore uses `runs//` in the configured workspace. +6. Inspect or trace the saved run in the terminal, benchmark its captured inputs, or open it + in the existing browser viewer with `compilerlens view`. Web Explore also publishes its + artifact and workload entry to the browser library under `frontend/public/artifacts/`. + +`compilerlens import` starts from existing dumps and builds a new analyzed run without +recompiling the model. The static developer workflow can also build browser artifacts from +existing dumps using `npm run artifact`. + +The Compiler Playground exports the selected Hugging Face model and compiles requested stages +into `jobs//`. It exposes IR, compiler signals, and optional timings directly through +job state. Restarting the API loses that in-memory state; generated files remain on disk. +The saved-capture path retains artifacts that remain available in the workload library. ### Model architecture to compiler bridge @@ -202,7 +226,7 @@ Each workload is represented by one normalized JSON document containing: - Model hierarchy, tensor shapes, parameter counts, and module-to-compiler mappings - Explicit notes for incomplete or unavailable compiler data -The Python schema is defined in `ingest/schema.py` and mirrored by +The Python schema is defined in `compilerlens/ingest/schema.py` and mirrored by `frontend/src/api/artifact.ts`. A schema change must update both definitions and increment the artifact version. @@ -212,94 +236,253 @@ artifact version. |---|---|---:|---|---| | Static explorer | `npm run artifact` + `npm run dev` | No | Generated artifact files | Explore existing compiler dumps | | Web model search | Model search field on the landing page | Yes | Dumps and artifact are retained | Add a Hugging Face model end to end | -| Compiler Playground | **Open the Compiler Playground** | Yes | In-memory job and temporary files | Test compiler options and selected stages | -| Command-line compilation | `scripts/compile_hf_model.py` | No | Dumps under `examples/` | Scriptable or offline compilation | +| Compiler Playground | **Open the Compiler Playground** | Yes | Job files; status in memory | Test compiler options and selected stages | +| CLI capture and analysis | `compilerlens compile` / `import` / `inspect` / `trace` | No | Self-contained run directory | Capture, query, compare, and benchmark models | +| Bundled viewer | `compilerlens view RUN` | Started by the command | Reads saved runs | Explore CLI captures in the browser | +| Repository compilation script | `scripts/compile_hf_model.py` | No | Dumps under `examples/` | Generate dumps for the static developer workflow | ### Repository layout ```text CompilerLens/ -├── backend/ -│ ├── api/ -│ │ ├── app.py Compilation, exploration, artifact, and benchmark endpoints -│ │ └── run_server.py API startup and toolchain validation -│ ├── compiler/ -│ │ └── runner.py Turbine export and multi-stage IREE compilation -│ └── measure/ Runtime benchmarking and controlled comparisons -├── models/ -│ ├── detect.py Hugging Face metadata and architecture detection -│ ├── architecture.py Exact torch.export module and operation capture -│ ├── hf_wrapper.py Traceable model wrapper and example inputs -│ └── prefetch.py Model caching for offline demonstrations -├── ingest/ -│ ├── build.py Artifact and landing-page index generation -│ ├── architecture.py Sidecar loading, fallback topology, and lineage enrichment -│ ├── schema.py Python definition of the artifact contract -│ ├── lineage.py Source-location-based operation lineage -│ ├── mlir_parser.py MLIR operation and dialect extraction -│ ├── llvm_parser.py LLVM summaries and debug-metadata lineage -│ ├── pass_log.py Per-pass snapshot extraction -│ └── workloads/ Static and generated workload specifications -├── frontend/ -│ ├── src/ -│ │ ├── api/ Artifact and live API clients -│ │ ├── components/ Shared viewers, timelines, and controls -│ │ ├── panes/ Dockable workspace panes -│ │ └── ArchitectureExplorerPage.tsx Interactive model-to-compiler map -│ ├── public/artifacts/ Generated artifacts served by Vite -│ ├── scripts/verify.mjs Browser-level regression suite -│ ├── package.json Frontend commands and dependencies -│ └── vite.config.ts Development server configuration -├── scripts/ -│ └── compile_hf_model.py Command-line Hugging Face compilation -├── docs/images/ Screenshots used by this documentation -├── examples/ Source programs and compiler dump directories -├── requirements.txt Pinned Python environment -└── README.md Setup, usage, and contribution guide +├── compilerlens/ Installable Python implementation +│ ├── cli.py compile/import/inspect/trace/diff/bench/view/doctor +│ ├── services.py Shared capture and import orchestration +│ ├── lineage.py LLVM report processing and source/assembly joins +│ ├── queries.py Queries over saved runs +│ ├── storage.py Durable run files and workspace selection +│ ├── server.py Serve the bundled viewer and API +│ ├── backend/ API, IREE compiler runner and benchmarking +│ ├── models/ Hugging Face detection, loading and architecture capture +│ ├── ingest/ Artifact construction, parsers and operation lineage +│ └── examples/ Packaged matmul, linear/ReLU and transformer examples +├── llvm/ C++ LLVM analysis, plugin, driver, tests and IREE hook +├── backend/, models/, ingest/ Compatibility imports for checkout commands +├── frontend/ React viewer, build configuration and browser checks +├── scripts/ Legacy model compiler, release build and install checks +├── docs/ CLI guide, release checklist and screenshots +├── examples/ Existing fixtures and legacy script output +├── runs/ Generated CLI/web captures (not tracked) +├── tests/ Python regression tests +├── pyproject.toml Package metadata and runtime dependencies +├── requirements.txt Pinned development environment +└── README.md Project overview and setup ``` ## System Requirements -- Linux x86-64 -- Python 3.10 -- Node.js 20.19+ or 22.12+; Node 22 is recommended -- Internet access for the initial dependency installation and uncached Hugging Face models +- Linux x86-64; the tested package wheel requires glibc 2.35 or newer +- Python 3.10 is the validated interpreter +- Internet access for installation and uncached Hugging Face models - Enough disk space for compiler dumps; even small models can produce tens or hundreds of MB +- For frontend development from source: Node.js 20.19+ or 22.12+; Node 22 is recommended -The pinned Python dependencies include the IREE compiler/runtime tools and CPU-only PyTorch. -No separate LLVM or IREE installation is required. +The package includes the IREE compiler/runtime dependencies and a prebuilt LLVM analyzer. +The installed CLI and bundled viewer do not require Node.js, CMake, or an LLVM SDK. +Source installation builds our LLVM pass. The [Miniconda setup below](#installation-from-source) +provides Python, Node.js, CMake, Ninja, a C++ compiler and LLVM in one user-local environment; +no sudo or separately installed LLVM/CMake is needed. -## Installation +## CLI Quick Start -From the repository root: +Use the installed CLI to capture a model, inspect its compiler stages, and open the same +browser viewer. A repository checkout is not required. + +### Create an environment and install + +This quick start targets **0.2.1** on Linux x86-64 with glibc 2.35+; Python 3.10 is +the validated interpreter. See the [release checklist](docs/RELEASING.md) for publication +status. Version 0.2.1 is published on PyPI and includes the CLI, native analyzer and webpage. ```bash +mkdir compilerlens-demo +cd compilerlens-demo python3.10 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip + +# Install CompilerLens from PyPI, using CPU builds of PyTorch. +python -m pip install \ + --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + compilerlens==0.2.1 + +python -m pip check +compilerlens doctor +``` + +The PyTorch CPU index supplies CPU builds for the supported workflow. CompilerLens itself +comes from PyPI. See the [release notes](docs/releases/0.2.1.md) for compatibility and +the [release checklist](docs/RELEASING.md) for validation details. + +### Compile, inspect, and open the viewer + +Start with the packaged matmul example, which needs no Hugging Face model download: + +```bash +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens inspect runs/matmul +compilerlens inspect runs/matmul --show stages +compilerlens trace runs/matmul --module model --to asm +compilerlens view runs/matmul --port 8000 +``` + +Open `http://127.0.0.1:8000` to explore the captured model architecture, compiler pipeline, +and operation lineage. Stop the viewer with Ctrl+C. Choose a new output directory for each +capture; CompilerLens refuses to overwrite an existing run. +If CompilerLens is running on a remote SSH server, use the +[SSH tunnel instructions below](#viewing-from-a-remote-ssh-server) to open it on your computer. + +To capture a Hugging Face model: + +```bash +compilerlens compile sshleifer/tiny-gpt2 --seq-len 8 --out runs/tiny-gpt2 +``` + +### Viewing from a remote SSH server + +Use **SSH tunneling (local port forwarding)** to reach the remote viewer from your own +computer. `127.0.0.1` refers to the machine where it is used: opening that address in your +local browser does not directly connect to the remote server. + +1. On the **remote server**, start the viewer and leave it running: + + ```bash + compilerlens view runs/matmul --port 8000 + ``` + +2. In a separate terminal on **your own computer**, start the tunnel: + + ```bash + ssh -N -L 8001:127.0.0.1:8000 username@remote-host + ``` + + Replace `username@remote-host` with your usual SSH destination or configured SSH alias. + This forwards local port `8001` to port `8000` on the remote server. + +3. Open **http://127.0.0.1:8001** in your computer's browser to view the saved run. + +Keep both the viewer and SSH tunnel running while using the webpage; Ctrl+C stops each +process. If local port `8001` is occupied, choose another local port in the tunnel command +and browser URL. `--open` attempts to launch a browser on the machine running CompilerLens; +it does not open your local browser through SSH. + +### Primary commands + +| Command | Purpose | +|---|---| +| `compilerlens doctor` | Check compiler tools, the LLVM analyzer, and bundled viewer assets | +| `compilerlens compile` | Capture a Hugging Face model, packaged example, or local Python factory | +| `compilerlens import DUMPS --out RUN` | Turn existing compiler dumps into a saved run | +| `compilerlens inspect RUN` | Inspect architecture, stages, evidence, operations, IR, or object sections | +| `compilerlens trace RUN` | Follow module/source associations into LLVM and assembly, or query them in reverse | +| `compilerlens diff RUN --from STAGE --to STAGE` | Compare compatible stages within a run | +| `compilerlens bench RUN` | Benchmark with the saved input tensors | +| `compilerlens view RUN` | Serve the saved run in the bundled browser viewer | + +See the **[detailed CLI guide](docs/CLI.md)** for selectors, JSON output, offline capture, +local Python factories, object-address lookup, and build instructions. The +[release checklist](docs/RELEASING.md) records PyPI validation and publication steps. +Version `0.2.1` adds ViT/CLIP capture and offline vision demos, while retaining the +existing CLI commands and the ability to read saved runs from versions `0.1.0` and `0.1.1`. + +## Installation from Source + +This path builds the Python/frontend application **and our native LLVM pass** from the +checkout. For the prebuilt application, use the [PyPI quick start](#cli-quick-start). + +Use **Linux x86-64 with glibc 2.35+** (for example, Ubuntu 22.04), internet access, and +several GB of free disk space. No sudo or GPU is required. [environment.yml](environment.yml) +provides Python 3.10, LLVM 22.1.8, the C/C++ build tools and Node.js 22; `requirements.txt` +provides the Python packages. Conda replaces the separate venv for source development. + +### 1. Install Miniconda once + +Skip installation if you already have Conda. Otherwise, install it under your own account: + +```bash +curl -fsSLo /tmp/compilerlens-miniconda.sh \ + https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh +bash /tmp/compilerlens-miniconda.sh -b -p "$HOME/miniconda3" +source "$HOME/miniconda3/etc/profile.d/conda.sh" +``` + +If curl is unavailable, download that installer using a browser. In new terminals, repeat +the `source` command to enable `conda activate` (adjust the path for an existing installation). +If a Python venv is currently active, run `deactivate` before activating Conda. + +### 2. Create the development environment + +From the root of your updated CompilerLens checkout: + +```bash +CONDARC="$PWD/.condarc" conda env create -f environment.yml +conda activate compilerlens-dev python -m pip install -r requirements.txt +python -m pip check +``` + +The project-local `.condarc` selects conda-forge without changing global Conda settings. +The environment excludes unrelated user-site Python packages. Do not create another venv +or install CompilerLens from PyPI inside this environment. +If you do not have the checkout or Git yet, use GitHub's **Code → Download ZIP** on the +branch or release tag you want to use and extract it first; the environment also installs +Git for later use. + +### 3. Build the pass and prepare the webpage + +From the repository root with `compilerlens-dev` active: + +```bash +cmake -S llvm -B build/llvm-conda -G Ninja \ + -DLLVM_DIR="$CONDA_PREFIX/lib/cmake/llvm" \ + -DPython3_EXECUTABLE="$CONDA_PREFIX/bin/python" \ + -DCMAKE_BUILD_TYPE=Release +cmake --build build/llvm-conda --parallel 2 +ctest --test-dir build/llvm-conda --output-on-failure +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" +"$COMPILERLENS_NATIVE" --version cd frontend npm ci -npm run artifact +npm run artifact -- --lineage required cd .. ``` -`npm run artifact` converts the compiler dumps under `examples/` into the JSON artifacts used -by the website. Run it again after changing ingest code or adding a model from the command line. +Conda activation selects the environment's compiler; `$CONDA_PREFIX` locates its LLVM SDK. +The separate `build/llvm-conda/` directory avoids old system-toolchain CMake caches. +CTest should pass, and artifact generation should report `Lineage: native`. +Use `--parallel 1` if the native build runs out of memory. Keep the Conda environment active +when running the analyzer: it uses libraries from that environment. + +Artifact generation updates the existing `examples/` workloads using their saved dumps, +without recompiling models or modifying original LLVM files. Repeat the build after C++ +changes, then repeat `npm run artifact -- --lineage required` from `frontend/` and refresh +the page. Native reports go into each workload's `native/` directory, mappings into +`lineage.json`, and viewer JSON into `frontend/public/artifacts/`. +Separate CLI captures under `runs/` are not automatically included. + +Lineage links source operations to LLVM and assembly instructions. `required` fails if native +analysis is unavailable or fails. Plain `npm run artifact` uses Automatic with fallback; +`npm run artifact -- --lineage off` skips native analysis. New Web Explore compilations +automatically use native analysis when available, with fallback if it is unavailable or +fails; viewing saved workloads does not rerun analysis. Missing compiler source locations +can still leave instructions unlinked. ## Running CompilerLens Locally -Start the API from the repository root: +After completing the source installation above, start the API from the repository root: ```bash -source .venv/bin/activate +conda activate compilerlens-dev +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" python -m backend.api.run_server ``` In a second terminal, start the frontend: ```bash +conda activate compilerlens-dev cd frontend npm run dev ``` @@ -309,6 +492,18 @@ Open the **Local** URL printed by Vite after `npm run dev` (for example, Keep both processes running while using model search or the Compiler Playground. The existing, pre-generated workload viewer only needs the frontend. +When running this source-development setup on a remote SSH server, start both processes +there, then forward the frontend and API ports from a terminal on **your own computer**: + +```bash +ssh -N -L 5173:127.0.0.1:5173 -L 8000:127.0.0.1:8000 username@remote-host +``` + +Replace the SSH destination with your own and open `http://127.0.0.1:5173` locally. +Use Vite's actual remote port if it selected a different one. Keep local port `8000` +forwarded for model search and the Compiler Playground, which use the API on that port. +For the installed CLI viewer, use the [single-port tunnel above](#viewing-from-a-remote-ssh-server). + If port 8000 is already in use, an API server is probably running in another terminal. Reuse that process or stop it before starting another one. @@ -347,22 +542,50 @@ the model. ## Supported Model Architectures -The automatic wrapper currently supports: +The CLI, Web Explore, Playground, and source scripts share the same model adapters: -- Encoder-only text models returning `last_hidden_state` -- Decoder-only text models returning `logits` -- Inputs shaped as `input_ids` plus a static 4D floating-point attention mask +| Family | Captured forward pass | +|---|---| +| Text encoders (BERT-like) | Token IDs + mask → hidden states | +| Causal text models (GPT-like) | Token IDs + mask → logits; no generation loop | +| ViT | Image tensor → hidden states; classification head excluded | +| CLIP | Image + text → embeddings and similarity logits | -Vision, audio, multimodal, and encoder-decoder models need additional input wrappers and are -rejected instead of being compiled with an incorrect signature. Sequence length is fixed at -compile time; the website uses 16 tokens for a compact first run. +Support covers the forward passes shown; tested checkpoints are listed below. +Compatibility with other checkpoints may vary. -## Command-Line Model Compilation +Pretrained models to try with ViT/CLIP support (source installation or the 0.2.1 wheel): -To compile without the landing-page UI: +- [`google/vit-base-patch16-224`](https://huggingface.co/google/vit-base-patch16-224) — 85.8M-parameter image encoder; tested through Web Explore. Captures patch embeddings and transformer blocks, not the classification head. +- [`wkcn/TinyCLIP-ViT-8M-16-Text-3M-YFCC15M`](https://huggingface.co/wkcn/TinyCLIP-ViT-8M-16-Text-3M-YFCC15M) — 23.45M parameters; validated image/text encoders, embeddings, and similarity logits. + +Paste either ID into the search bar or pass it to `python -m compilerlens compile MODEL_ID`. +Validation uses static, batch-one CPU/float32 workloads with synthetic inputs. Audio, +other multimodal families, encoder-decoder models, and real-media preprocessing remain future work. + +Try the offline demos from the repo root with your source environment active +(random weights; no downloads or extra dependencies): ```bash -source .venv/bin/activate +python -m compilerlens compile --example tiny_vit --out examples/tiny_vit +python -m compilerlens compile --example tiny_clip --out examples/tiny_clip +cd frontend +npm run artifact +``` + +Refresh the [source webpage](#running-compilerlens-locally); use fresh output paths if these +directories exist. These demos require `0.2.1` or the current source checkout. Native lineage remains automatic; +ViT/CLIP captures on host/generic CPU also compare compiled outputs with PyTorch and save +`verification.json`. See the [CLI guide](docs/CLI.md#validated-hf-checkpoints) for pinned revisions and validation commands. + +## Repository Compilation Script + +For the installed package, use the [CLI quick start](#cli-quick-start). The repository also +provides a developer script for generating dumps and rebuilding the static artifact library: + +```bash +conda activate compilerlens-dev +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" python scripts/compile_hf_model.py prajjwal1/bert-tiny --seq-len 16 cd frontend npm run artifact @@ -390,21 +613,23 @@ Choose **Open the Compiler Playground** from the landing page to: - Inspect the resulting IR and compiler signals - Run an explicit whole-model benchmark -The model selector combines a small set of baseline models with every successful persisted -compilation found under `examples/*/model_info.json`. Reopening the Playground refreshes this list, +The model selector combines a small set of baseline models with persisted captures found under +`examples/*/model_info.json` and `runs/*/model_info.json`. Reopening the Playground refreshes this list, so models added through landing-page search or the command-line compiler appear automatically. Playground jobs are stored in memory and disappear when the API restarts. Hugging Face models -compiled through landing-page search are persisted under `examples/` and remain available after -artifact regeneration. +compiled through landing-page search are persisted under `runs//`, with viewer artifacts +published to `frontend/public/artifacts/`. Installed builds use the user cache workspace by +default; `COMPILERLENS_WORKSPACE` overrides it. ## Validation and Testing Run the backend syntax checks and frontend production build: ```bash -source .venv/bin/activate -python -m py_compile backend/api/app.py backend/api/run_server.py ingest/build.py ingest/schema.py models/architecture.py ingest/architecture.py +conda activate compilerlens-dev +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" +python -m py_compile compilerlens/backend/api/app.py compilerlens/backend/api/run_server.py compilerlens/ingest/build.py compilerlens/ingest/schema.py compilerlens/models/architecture.py compilerlens/ingest/architecture.py python -m unittest discover -s tests -v cd frontend @@ -421,7 +646,11 @@ npm run verify ## Troubleshooting -- `iree-compile not found`: activate `.venv` before starting the API or compiler script. +- Remote viewer starts but the browser cannot connect: follow the + [SSH tunnel instructions](#viewing-from-a-remote-ssh-server), open the local forwarded URL, + and keep both the viewer and tunnel running. +- `iree-compile not found`: activate `compilerlens-dev` for source development, or `.venv` + for the PyPI quick start, before starting the API or compiler script. - API unavailable in the UI: confirm `python -m backend.api.run_server` is listening on port 8000. - Hugging Face download failure: check the model ID, network access, authentication for gated diff --git a/backend/__init__.py b/backend/__init__.py index e69de29..e01f837 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -0,0 +1,3 @@ +"""Source-checkout compatibility; installed API is compilerlens.backend.""" +from compilerlens import backend as _package +__path__ = _package.__path__ diff --git a/compilerlens/__init__.py b/compilerlens/__init__.py new file mode 100644 index 0000000..825de8c --- /dev/null +++ b/compilerlens/__init__.py @@ -0,0 +1,13 @@ +"""CompilerLens: model-aware compiler inspection and lineage.""" + +__version__ = "0.2.1" + + +def capture(*args, **kwargs): + from .services import capture as implementation + return implementation(*args, **kwargs) + + +def load_report(path): + from .storage import load_run + return load_run(path) diff --git a/compilerlens/__main__.py b/compilerlens/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/compilerlens/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/backend/api/__init__.py b/compilerlens/backend/__init__.py similarity index 100% rename from backend/api/__init__.py rename to compilerlens/backend/__init__.py diff --git a/backend/compiler/__init__.py b/compilerlens/backend/api/__init__.py similarity index 100% rename from backend/compiler/__init__.py rename to compilerlens/backend/api/__init__.py diff --git a/backend/api/app.py b/compilerlens/backend/api/app.py similarity index 74% rename from backend/api/app.py rename to compilerlens/backend/api/app.py index a4ffbdc..438357c 100644 --- a/backend/api/app.py +++ b/compilerlens/backend/api/app.py @@ -19,19 +19,25 @@ import logging import re import shutil +from compilerlens.toolchain import find_tool import subprocess import tempfile import time +import threading import uuid from pathlib import Path +from typing import Literal from fastapi import BackgroundTasks, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field -REPO_ROOT = Path(__file__).resolve().parents[2] +from compilerlens.storage import workspace, atomic_json + +REPO_ROOT = workspace() LOGGER = logging.getLogger(__name__) +_PUBLISH_LOCK = threading.Lock() _MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._-]*)?") _PUBLIC_ERROR_LIMIT = 500 # Full-stage artifacts duplicate a model's constants into textual MLIR. The largest verified @@ -118,7 +124,9 @@ class ExploreModelTooLargeError(RuntimeError): class CompileRequest(BaseModel): model_id: str - seq_len: int = 32 + seq_len: int = Field(default=32, ge=1, le=512) + revision: str | None = None + offline: bool = False stages: list = Field(default_factory=lambda: ["executable-targets"]) options: dict = Field(default_factory=dict) # e.g. {"target-cpu": "generic"} want_asm: bool = False @@ -129,6 +137,9 @@ class ExploreRequest(BaseModel): model_id: str = Field(min_length=1, max_length=200) seq_len: int = Field(default=16, ge=1, le=512) + lineage: Literal['auto', 'required', 'off'] = 'auto' + revision: str | None = None + offline: bool = False class BenchmarkRequest(BaseModel): @@ -136,7 +147,7 @@ class BenchmarkRequest(BaseModel): def _tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise HTTPException(status_code=503, detail=f"'{name}' not on PATH; the server needs the project venv active.") return path @@ -179,7 +190,7 @@ def _sandbox_models() -> list[str]: seen = set(models) examples_dir = REPO_ROOT / "examples" - for path in sorted(examples_dir.glob("*/model_info.json")): + for path in sorted([*examples_dir.glob("*/model_info.json"), *(REPO_ROOT / "runs").glob("*/model_info.json")]): try: model_id = str(json.loads(path.read_text()).get("model_id") or "").strip() except (OSError, ValueError, TypeError): @@ -203,6 +214,7 @@ def compile_model(request: CompileRequest, background: BackgroundTasks): "status": "running", "model_id": request.model_id, "seq_len": request.seq_len, + "revision": request.revision, "offline": request.offline, "options": request.options, "flags": flags, "stages": {}, @@ -232,114 +244,60 @@ def explore_model(request: ExploreRequest, background: BackgroundTasks): job_id = str(uuid.uuid4())[:8] JOBS[job_id] = { "status": "running", "model_id": model_id, "seq_len": request.seq_len, + "revision": request.revision, "offline": request.offline, + "lineage": request.lineage, "options": {}, "flags": [], "stages": {}, "bench": None, "error": None, "compile_seconds": None, "work_dir": None, "artifact_id": None, "progress": None, } - background.add_task(_run_explore, job_id, model_id, request.seq_len) + background.add_task(_run_explore, job_id, model_id, request.seq_len, request.lineage, + revision=request.revision, offline=request.offline) return {"job_id": job_id, "status": "running"} -def _run_explore(job_id: str, model_id: str, seq_len: int) -> None: - """Run the established full dump -> ingest pipeline and publish its artifact.""" +def _run_explore(job_id: str, model_id: str, seq_len: int, lineage: str = 'auto', *, + revision: str | None = None, offline: bool = False) -> None: + """Use the same durable capture and native lineage pipeline as the CLI.""" + from compilerlens.services import capture job = JOBS[job_id] started = time.monotonic() - phase = "looking up the model" + phase = "loading and compiling the model" try: - from backend.compiler.runner import ( - DEFAULT_STAGES, - CompilerRunner, - RunConfig, - TRIM_CODEGEN_PASSES, - TRIM_ELIDE_ATTRS, - ) - from ingest.build import build_artifact, build_index - from ingest.workloads.generated import spec_from_dump_dir - from models.detect import detect - from models.hf_wrapper import wrap, wrapper_source - - # Detect/load/build-artifact/publish, plus `CompilerRunner.run()`'s own real - # checkpoint count (export + one per compiled stage + 4 more phases -- see - # runner.py). Known up front since `_run_explore` never overrides `stages`, so the - # progress bar's denominator never has to guess or change mid-run. - outer_steps = 4 - grand_total = outer_steps + (5 + len(DEFAULT_STAGES)) - done = 0 - - def report(label: str) -> None: - nonlocal done - done += 1 - job["progress"] = {"label": label, "done": done, "total": grand_total} - - detected = detect(model_id, seq_len=seq_len) - report("Detected model architecture") - phase = "loading the model" - module, example_inputs, model_info = wrap(detected) - report("Loaded and exported the model") - param_count = int(model_info.get("param_count") or 0) - if param_count > _MAX_EXPLORE_PARAMETERS: - raise ExploreModelTooLargeError( - f"{param_count} parameters exceeds the {_MAX_EXPLORE_PARAMETERS} parameter interactive limit" - ) - output_dir = REPO_ROOT / "examples" / detected.slug - phase = "exporting and compiling the model" - runner = CompilerRunner(RunConfig( - layout="ingest", elide_attrs_larger_than=TRIM_ELIDE_ATTRS, - pass_log_after=TRIM_CODEGEN_PASSES, - )) - result = runner.run( - module, example_inputs, name=detected.slug, output_dir=output_dir, model_info=model_info, - on_progress=lambda label, _done, _total: report(label), - ) - manifest = json.loads(result.manifest_path.read_text()) - if manifest.get("errors"): - raise RuntimeError(_compiler_failure_message(manifest["errors"])) - - phase = "building the CompilerLens artifact" - report("Building the CompilerLens artifact") - (output_dir / "source.py").write_text(wrapper_source(detected)) - (output_dir / "model_info.json").write_text(json.dumps(model_info, indent=2)) - spec = spec_from_dump_dir(output_dir, model_info) - artifact = build_artifact(spec, root=output_dir) - artifacts_dir = REPO_ROOT / "frontend" / "public" / "artifacts" - artifacts_dir.mkdir(parents=True, exist_ok=True) - artifact_path = artifacts_dir / f"{spec.id}.json" - artifact_path.write_text(artifact.to_json()) - - report("Publishing") - index_path = artifacts_dir / "index.json" - existing = json.loads(index_path.read_text()) if index_path.is_file() else {"workloads": []} - entry = build_index({spec.id: spec}, {spec.id: artifact})["workloads"][0] - existing["workloads"] = [w for w in existing.get("workloads", []) if w.get("id") != spec.id] + [entry] - index_path.write_text(json.dumps(existing)) - # As in scripts/compile_hf_model.py, the untrimmed export only exists so IREE can - # compile it. Keeping it would duplicate large model weights for no viewer benefit. - full_dir = output_dir / "_full" - if full_dir.is_dir(): - shutil.rmtree(full_dir) - job.update(status="done", model_info=model_info, artifact_id=spec.id) - except Exception as exc: # noqa: BLE001 - a background job must report its failure - # The UI gets an actionable, bounded explanation. Keep the complete exception and - # traceback in the server terminal for developers diagnosing an unfamiliar model. - LOGGER.exception("Explore job %s failed while %s (%s)", job_id, phase, model_id) - job.update(status="failed", error=_friendly_explore_error(exc, model_id, phase)) + def report(label): + nonlocal phase + phase = label + previous = (job.get('progress') or {}).get('done', 0) + job['progress'] = {'label': label, 'done': previous + 1, 'total': max(previous + 2, 24)} + output_dir = capture(model_id, seq_len=seq_len, + out=REPO_ROOT / 'runs' / job_id, + max_parameters=_MAX_EXPLORE_PARAMETERS, progress=report, + lineage=lineage, revision=revision, offline=offline) + info = json.loads((output_dir / 'model_info.json').read_text()) + artifact = json.loads((output_dir / 'artifact.json').read_text()) + artifact_id = artifact['compilation_id'] + artifacts_dir = REPO_ROOT / 'frontend/public/artifacts' + atomic_json(artifacts_dir / f'{artifact_id}.json', artifact) + # Serialise the read-modify-write if several background jobs finish together. + with _PUBLISH_LOCK: + index_path = artifacts_dir / 'index.json' + existing = json.loads(index_path.read_text()) if index_path.exists() else {'workloads': []} + entry = json.loads((output_dir / 'index.json').read_text())['workloads'][0] + existing['workloads'] = [w for w in existing['workloads'] if w['id'] != artifact_id] + [entry] + atomic_json(index_path, existing) + job.update(status='done', model_info=info, artifact_id=artifact_id, + progress={'label': 'Complete', 'done': 1, 'total': 1}) + except Exception as exc: + LOGGER.exception('Explore job %s failed (%s)', job_id, model_id) + job.update(status='failed', error=_friendly_explore_error(exc, model_id, phase)) finally: - job["compile_seconds"] = round(time.monotonic() - started, 2) + job['compile_seconds'] = round(time.monotonic() - started, 2) + atomic_json(REPO_ROOT / 'jobs' / f'{job_id}.json', job) def _normalize_model_id(value: str) -> str: """Accept either a Hub repository ID or a pasted huggingface.co model URL.""" - model_id = value.strip().rstrip("/") - prefix = "https://huggingface.co/" - if model_id.startswith(prefix): - parts = model_id[len(prefix):].split("/") - # Ignore URL suffixes such as /tree/main. Official repositories can have a single - # segment; community repositories normally have owner/name. - if len(parts) > 1 and parts[1] not in {"blob", "commit", "discussions", "resolve", "tree"}: - model_id = "/".join(parts[:2]) - else: - model_id = parts[0] if parts else "" - return model_id + from compilerlens.models.detect import normalize_model_id + return normalize_model_id(value) def _one_line(value: object, limit: int = _PUBLIC_ERROR_LIMIT) -> str: @@ -365,7 +323,15 @@ def _friendly_explore_error(exc: Exception, model_id: str, phase: str) -> str: lower = raw.lower() kind = type(exc).__name__.lower() - if isinstance(exc, ExploreModelTooLargeError): + if phase.startswith('Native lineage:') or any(token in lower for token in ( + 'native analyzer', 'native analysis', 'required native', + )): + return ( + 'Native lineage analysis could not complete. Check the native analyzer on the server, ' + 'or choose Automatic or Off to compile without requiring native analysis.' + ) + + if isinstance(exc, ExploreModelTooLargeError) or 'parameter interactive limit' in lower: count_match = re.search(r"(\d+) parameters", raw) count = int(count_match.group(1)) if count_match else 0 size = f" ({count / 1_000_000:.0f}M parameters)" if count else "" @@ -373,6 +339,11 @@ def _friendly_explore_error(exc: Exception, model_id: str, phase: str) -> str: f"'{model_id}' is too large for interactive full-pipeline capture{size}. " "Use a model below 100M parameters; larger models can generate multi-gigabyte IR artifacts." ) + from compilerlens.models.detect import UnsupportedModelError + if isinstance(exc, UnsupportedModelError): + return raw + if 'compiled output verification failed' in lower: + return 'Compiled outputs did not pass numerical verification against PyTorch. See verification.json in the run for details.' if any(token in lower or token in kind for token in ("repository not found", "not found", "404")): return ( f"We couldn't find a public Hugging Face model named '{model_id}'. " @@ -387,8 +358,8 @@ def _friendly_explore_error(exc: Exception, model_id: str, phase: str) -> str: return "CompilerLens could not reach Hugging Face. Check the server's network connection and try again." if "no vocab_size" in lower: return ( - f"'{model_id}' is not a supported text model. CompilerLens currently expects a model " - "that accepts token IDs, such as a BERT-like encoder or GPT-like decoder." + f"'{model_id}' has no supported input adapter. Try a text encoder/causal LM, " + "ViT image encoder, or CLIP image-text model. Other families are not supported yet." ) if "encoder-decoder" in lower or "decoder_input_ids" in lower: return ( @@ -405,7 +376,7 @@ def _friendly_explore_error(exc: Exception, model_id: str, phase: str) -> str: if phase == "loading the model": return ( f"Hugging Face found '{model_id}', but CompilerLens could not load it with the current " - "text-model adapter. See the API terminal for the technical details." + "model adapter. See the API terminal for the technical details." ) if phase == "exporting and compiling the model": return ( @@ -423,22 +394,24 @@ def _run_compile(job_id: str, request: CompileRequest, flags: list) -> None: try: import iree.turbine.aot as aot - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap - detected = detect(request.model_id, seq_len=request.seq_len) - module, example_inputs, model_info = wrap(detected) + detected = detect(request.model_id, seq_len=request.seq_len, + revision=request.revision, offline=request.offline) + module, example_inputs, model_info = wrap(detected, max_parameters=_MAX_EXPLORE_PARAMETERS) job["model_info"] = model_info - work_dir = Path(tempfile.mkdtemp(prefix=f"sandbox-{job_id}-")) + work_dir = REPO_ROOT / "jobs" / job_id + work_dir.mkdir(parents=True, exist_ok=False) job["work_dir"] = str(work_dir) mlir_path = work_dir / "input.mlir" mlir_path.write_text(str(aot.export(module, *example_inputs).mlir_module)) - from backend.measure.compare import _input_specs + from compilerlens.backend.measure.compare import _input_specs - job["input_specs"] = _input_specs(example_inputs) + job["input_specs"] = _input_specs(example_inputs, work_dir) base = [ _tool("iree-compile"), @@ -464,6 +437,15 @@ def _run_compile(job_id: str, request: CompileRequest, flags: list) -> None: return # Only the stages that were asked for -- this is what keeps a flag change ~1s. + if model_info.get('adapter') in ('vit', 'clip'): + from compilerlens.models.verification import verify_forward + # Playground flags can target a different CPU, which is unsafe to execute here. + cpu_flag = next((flag.split('=', 1)[1] for flag in flags + if flag.startswith('--iree-llvmcpu-target-cpu=')), 'host') + if cpu_flag in ('host', 'generic'): + job['verification'] = verify_forward(vmfb, module, example_inputs, + model_info['output_names'], work_dir / 'verification.json') + for stage in request.stages: path = work_dir / f"ir_{STAGE_INDEX[stage]}_{stage}.mlir" stage_result = subprocess.run( @@ -486,7 +468,7 @@ def _run_compile(job_id: str, request: CompileRequest, flags: list) -> None: def _signals(work_dir: Path) -> dict: - from backend.measure.compare import _read_signals + from compilerlens.backend.measure.compare import _read_signals return _read_signals(work_dir) @@ -510,6 +492,7 @@ def get_job(job_id: str): "bench": job["bench"], "error": job["error"], "artifact_id": job.get("artifact_id"), + "lineage": job.get("lineage"), "progress": job.get("progress"), } @@ -534,7 +517,7 @@ def benchmark_job(job_id: str, request: BenchmarkRequest): if job["status"] != "done": raise HTTPException(status_code=409, detail=f"job is '{job['status']}'") - from backend.measure.bench import BenchmarkError, benchmark_module + from compilerlens.backend.measure.bench import BenchmarkError, benchmark_module vmfb = Path(job["work_dir"]) / "module.vmfb" try: diff --git a/backend/api/run_server.py b/compilerlens/backend/api/run_server.py similarity index 96% rename from backend/api/run_server.py rename to compilerlens/backend/api/run_server.py index 7aa08ff..50ce780 100644 --- a/backend/api/run_server.py +++ b/compilerlens/backend/api/run_server.py @@ -50,7 +50,7 @@ def main() -> int: print(f" GET /compile/{{id}} status + signals") print(f" GET /compile/{{id}}/stage/{{stage}} raw IR") print(f" POST /compile/{{id}}/benchmark opt-in timing") - uvicorn.run("backend.api.app:app", host=HOST, port=PORT, reload=False) + uvicorn.run("compilerlens.backend.api.app:app", host=HOST, port=PORT, reload=False) return 0 diff --git a/backend/measure/__init__.py b/compilerlens/backend/compiler/__init__.py similarity index 100% rename from backend/measure/__init__.py rename to compilerlens/backend/compiler/__init__.py diff --git a/backend/compiler/runner.py b/compilerlens/backend/compiler/runner.py similarity index 92% rename from backend/compiler/runner.py rename to compilerlens/backend/compiler/runner.py index 7ee7114..8e6f9a6 100644 --- a/backend/compiler/runner.py +++ b/compilerlens/backend/compiler/runner.py @@ -9,6 +9,8 @@ import json import re import shutil +from compilerlens.toolchain import find_tool +from compilerlens.examples import EXAMPLES import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -82,6 +84,7 @@ def _strip_dialect_resources(text: str) -> str: class RunConfig: target_backend: str = "llvm-cpu" target_cpu: str = "host" + debug_symbols: bool = False stages: tuple = DEFAULT_STAGES # "flat" keeps everything in one directory (the original behaviour, used by the # hand-written examples). "ingest" writes the mlir/ llvm/ passes/ layout that @@ -122,7 +125,7 @@ def __init__(self, config: RunConfig = None): @staticmethod def _require_tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise RuntimeError( f"'{name}' not found on PATH. Activate the project venv " @@ -193,7 +196,8 @@ def run( # One real checkpoint per stage `_dump_named_stages` actually compiles, plus the five # other phases below -- a true count of what `run()` does, not a guessed one, so a # caller can show honest progress instead of an animated bar with no real meaning. - total_steps = 5 + len(self.config.stages) + verify = model_info is not None and model_info.get('adapter') in ('vit', 'clip') + total_steps = 5 + len(self.config.stages) + int(verify) progress = {"done": 0} def report(label: str) -> None: @@ -208,6 +212,22 @@ def report(label: str) -> None: self._dump_named_stages(torch_input_path, mlir_dir, manifest, report) self._dump_llvm_intermediates(torch_input_path, output_dir, dumps_dir, manifest) report("Capturing LLVM intermediates") + if verify: + from compilerlens.models.verification import verify_forward + from compilerlens.storage import atomic_json + verification_path = output_dir / 'verification.json' + if manifest['errors']: + atomic_json(verification_path, {'status': 'skipped', 'reason': 'Compilation failed.'}) + elif self.config.target_backend != 'llvm-cpu' or self.config.target_cpu not in ('host', 'generic'): + atomic_json(verification_path, {'status': 'skipped', 'reason': 'Verification requires host or generic CPU.'}) + else: + try: + verify_forward(output_dir / f'{output_dir.name}_compiled_host.vmfb', module, + example_input_args, model_info['output_names'], verification_path) + except Exception as exc: + manifest['errors'].append({'stage': 'verification', 'stderr': str(exc)}) + manifest['files']['verification.json'] = str(verification_path) + report('Verifying compiled outputs') self._dump_full_pass_traces(torch_input_path, mlir_dir, passes_dir, manifest) report("Capturing per-pass traces") self._summarize_operators(mlir_dir, manifest) @@ -246,7 +266,7 @@ def _export_torch_input( # Export exactly once and pass that same decomposed program into Turbine. Besides # avoiding duplicate tracing, this preserves nn_module_stack metadata long enough # to establish an exact module-to-Torch-MLIR mapping. - from models.architecture import capture_architecture, export_program + from compilerlens.models.architecture import capture_architecture, export_program exported_program = None if model_info is not None: @@ -324,6 +344,7 @@ def _dump_llvm_intermediates(self, torch_input_path: Path, output_dir: Path, dum str(torch_input_path), f"--iree-hal-target-backends={self.config.target_backend}", f"--iree-llvmcpu-target-cpu={self.config.target_cpu}", + *(["--iree-hal-executable-debug-level=3", "--iree-llvmcpu-debug-symbols=true"] if self.config.debug_symbols else []), f"--iree-hal-dump-executable-sources-to={dumps_dir}", f"--iree-hal-dump-executable-intermediates-to={dumps_dir}", f"--iree-hal-dump-executable-binaries-to={dumps_dir}", @@ -378,7 +399,7 @@ def _dump_full_pass_traces(self, torch_input_path: Path, mlir_dir: Path, passes_ "--iree-transformation-pipeline", "--mlir-print-debuginfo", *self._pass_print_flags(), - "--mlir-elide-elementsattrs-if-larger=8", + *self._elide_flags(), "-o", str(final_vm_output), ] @@ -412,13 +433,6 @@ def _summarize_dispatches(self, output_dir: Path, manifest: dict) -> None: manifest["unique_dispatches"] = len(set(normalized_bodies)) -EXAMPLES = { - "matmul": "examples.matmul", - "linear_relu": "examples.linear_relu", - "mini_transformer": "examples.mini_transformer", -} - - def _load_example(name: str): import importlib @@ -435,8 +449,8 @@ def _load_hf_model(model_id: str, seq_len: int): Kept out of EXAMPLES because these are not enumerable: any Hub id works, so it is a separate flag rather than a registry entry. """ - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap return wrap(detect(model_id, seq_len=seq_len)) diff --git a/compilerlens/backend/measure/__init__.py b/compilerlens/backend/measure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/measure/bench.py b/compilerlens/backend/measure/bench.py similarity index 96% rename from backend/measure/bench.py rename to compilerlens/backend/measure/bench.py index dbfb61c..baca4f8 100644 --- a/backend/measure/bench.py +++ b/compilerlens/backend/measure/bench.py @@ -14,6 +14,7 @@ import json import shutil +from compilerlens.toolchain import find_tool import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -62,7 +63,7 @@ class BenchmarkError(RuntimeError): def _tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise BenchmarkError( f"'{name}' not found on PATH. Activate the project venv before benchmarking." @@ -80,6 +81,7 @@ def benchmark_module( function: str = "main", repetitions: int = MIN_REPETITIONS, device: str = "local-task", + workers: int | None = None, ) -> BenchResult: """Time one entry point of a compiled .vmfb. @@ -97,9 +99,10 @@ def benchmark_module( f"--benchmark_repetitions={repetitions}", "--benchmark_format=json", ] + if workers is not None: cmd.append(f"--task_topology_max_group_count={workers}") cmd += [f"--input={spec}" for spec in inputs] - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) if result.returncode != 0 or not result.stdout.strip(): raise BenchmarkError( f"iree-benchmark-module failed (exit {result.returncode}): " diff --git a/backend/measure/cli.py b/compilerlens/backend/measure/cli.py similarity index 92% rename from backend/measure/cli.py rename to compilerlens/backend/measure/cli.py index d88977f..d612260 100644 --- a/backend/measure/cli.py +++ b/compilerlens/backend/measure/cli.py @@ -16,13 +16,13 @@ import sys from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) +from compilerlens.storage import workspace -from backend.measure.compare import run_experiment # noqa: E402 -from backend.measure.variants import DIMENSIONS # noqa: E402 -from models.detect import UnsupportedModelError # noqa: E402 +REPO_ROOT = workspace() + +from compilerlens.backend.measure.compare import run_experiment # noqa: E402 +from compilerlens.backend.measure.variants import DIMENSIONS # noqa: E402 +from compilerlens.models.detect import UnsupportedModelError # noqa: E402 def _print_dimensions() -> None: diff --git a/backend/measure/compare.py b/compilerlens/backend/measure/compare.py similarity index 92% rename from backend/measure/compare.py rename to compilerlens/backend/measure/compare.py index 99caf35..bff430b 100644 --- a/backend/measure/compare.py +++ b/compilerlens/backend/measure/compare.py @@ -11,6 +11,7 @@ import re import shutil +from compilerlens.toolchain import find_tool import subprocess import time from dataclasses import dataclass, field @@ -18,8 +19,8 @@ import iree.turbine.aot as aot -from backend.measure.bench import BenchResult, benchmark_module, compare_timings -from backend.measure.variants import Dimension, Variant +from compilerlens.backend.measure.bench import BenchResult, benchmark_module, compare_timings +from compilerlens.backend.measure.variants import Dimension, Variant # Signals we read out of the compiled output to explain a timing difference. Each is a fact # the compiler emitted, not an interpretation of one. @@ -72,24 +73,17 @@ def as_dict(self) -> dict: def _tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise RuntimeError(f"'{name}' not found on PATH; activate the venv.") return path -def _input_specs(example_inputs: tuple) -> list: - """iree-run-module input descriptors for the traced example inputs. - - Values are fixed (1 for ids, 0 for the additive mask) so a benchmark is reproducible and - so the mask means "attend everywhere" rather than something arbitrary. - """ - specs = [] - for index, tensor in enumerate(example_inputs): - shape = "x".join(str(d) for d in tensor.shape) - dtype = {"torch.int64": "i64", "torch.float32": "f32"}.get(str(tensor.dtype), "f32") - specs.append(f"{shape}x{dtype}={1 if index == 0 else 0}") - return specs +def _input_specs(example_inputs: tuple, output_dir: Path) -> list: + """Persist actual tensors, including causal masks, for IREE's NPY reader.""" + from compilerlens.services import save_inputs + records = save_inputs(example_inputs, output_dir) + return ['@' + str((output_dir / r['path']).resolve()) for r in records] def _read_signals(output_dir: Path) -> dict: @@ -238,8 +232,8 @@ def run_experiment( benchmark: bool = True, ) -> dict: """Compile and time every variant of `dimension`, then compare them pairwise.""" - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap detected = detect(model_id, seq_len=seq_len) module, example_inputs, model_info = wrap(detected) @@ -251,7 +245,7 @@ def run_experiment( # program with different flags. mlir_path = output_root / "input.mlir" mlir_path.write_text(str(aot.export(module, *example_inputs).mlir_module)) - input_specs = _input_specs(example_inputs) + input_specs = _input_specs(example_inputs, output_root) runs = [] for variant in dimension.variants: diff --git a/backend/measure/variants.py b/compilerlens/backend/measure/variants.py similarity index 100% rename from backend/measure/variants.py rename to compilerlens/backend/measure/variants.py diff --git a/compilerlens/benchmark.py b/compilerlens/benchmark.py new file mode 100644 index 0000000..17a8dc2 --- /dev/null +++ b/compilerlens/benchmark.py @@ -0,0 +1,24 @@ +"""Benchmark saved executables against their captured input values.""" +import json +from pathlib import Path + +from .storage import load_run, digest, atomic_json + + +def benchmark_run(run, *, workers=8, repetitions=10): + from .backend.measure.bench import benchmark_module + root, artifact, _ = load_run(run) + index = root / 'inputs/index.json' + if not index.is_file(): raise ValueError('This run has no saved inputs. Recompile to benchmark exact tensors.') + inputs = [] + for record in json.loads(index.read_text()): + path = (root / record['path']).resolve() + if not path.is_relative_to(root) or not path.is_file() or digest(path) != record['sha256']: + raise ValueError('Captured input is missing or its hash changed.') + inputs.append('@' + str(path)) + executables = list(root.glob('*.vmfb')) + if len(executables) != 1: raise ValueError('Expected exactly one saved .vmfb executable.') + result = benchmark_module(executables[0], inputs, repetitions=repetitions, workers=workers).as_dict() + result.update(workers=workers, input_values='exact saved tensors', compilation_id=artifact['compilation_id']) + atomic_json(root / 'benchmark.json', result) + return result diff --git a/compilerlens/cli.py b/compilerlens/cli.py new file mode 100644 index 0000000..ad7d489 --- /dev/null +++ b/compilerlens/cli.py @@ -0,0 +1,212 @@ +"""CompilerLens command line. Diagnostics go to stderr; JSON stdout stays clean.""" +from __future__ import annotations + +import argparse +import contextlib +import json +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +from . import __version__ +from .examples import EXAMPLES +from .storage import load_run + + +def positive(value): + number = int(value) + if number <= 0: raise argparse.ArgumentTypeError('must be positive') + return number + + +def line_range(value): + try: a, b = map(int, value.split(':')) + except ValueError: raise argparse.ArgumentTypeError('expected START:END') from None + if a < 1 or b < a: raise argparse.ArgumentTypeError('expected 1 <= START <= END') + return a, b + + +def parser(): + p = argparse.ArgumentParser(prog='compilerlens', description='Inspect model compilation from Torch to MLIR, LLVM and assembly.') + p.add_argument('--version', action='version', version=f'CompilerLens {__version__}') + sub = p.add_subparsers(dest='command', required=True) + compile = sub.add_parser('compile', help='Compile and capture a model into a fresh run') + compile.add_argument('model_id', nargs='?') + inputs = compile.add_mutually_exclusive_group() + inputs.add_argument('--example', choices=tuple(EXAMPLES)) + inputs.add_argument('--python', help='Local file.py:factory returning (module, example_inputs)') + compile.add_argument('--seq-len', type=positive, help='HF static sequence length (default: 16)') + compile.add_argument('--revision', help='HF commit or reference; resolved commit is saved') + compile.add_argument('--offline', action='store_true') + compile.add_argument('--seed', type=int, default=0) + compile.add_argument('--cpu', default='host') + compile.add_argument('--capture', choices=('standard', 'full'), default='standard') + compile.add_argument('--lineage', choices=('auto', 'required', 'off'), default='auto') + compile.add_argument('--out', type=Path) + imported = sub.add_parser('import', help='Analyze existing mlir/llvm/passes dumps') + imported.add_argument('source', type=Path) + imported.add_argument('--out', type=Path) + imported.add_argument('--lineage', choices=('auto', 'required', 'off'), default='auto') + inspect = sub.add_parser('inspect', help='Inspect saved results without recompiling') + inspect.add_argument('run', type=Path) + inspect.add_argument('--show', choices=('summary', 'architecture', 'stages', 'evidence', 'ops', 'ir', 'objects'), default='summary') + inspect.add_argument('--stage') + inspect.add_argument('--module') + inspect.add_argument('--lines', type=line_range) + trace = sub.add_parser('trace', help='Query compiler-recorded source attribution') + trace.add_argument('run', type=Path) + selectors = trace.add_mutually_exclusive_group(required=True) + for name in ('op', 'module', 'source', 'from-stage', 'object'): selectors.add_argument('--' + name) + trace.add_argument('--line', type=positive, help='1-based display line for reverse lookup') + trace.add_argument('--section', help='Object section, e.g. .text') + trace.add_argument('--address', help='Section-relative byte offset, decimal or 0xhex') + trace.add_argument('--to', choices=('llvm', 'asm', 'all'), default='all') + diff = sub.add_parser('diff', help='Compare stages on the same compilation track') + diff.add_argument('run', type=Path) + diff.add_argument('--from', dest='before', required=True) + diff.add_argument('--to', dest='after', required=True) + diff.add_argument('--mode', choices=('text', 'semantic'), default='text') + view = sub.add_parser('view', help='Serve this run in the existing bundled webpage') + view.add_argument('run', type=Path) + view.add_argument('--port', type=positive, default=8000) + view.add_argument('--open', action='store_true', dest='open_browser') + doctor = sub.add_parser('doctor', help='Check installed compiler and native components') + bench = sub.add_parser('bench', help='Benchmark using the exact captured input tensors') + bench.add_argument('run', type=Path) + bench.add_argument('--workers', type=positive, default=8) + bench.add_argument('--repetitions', type=positive, default=10) + for command in (compile, imported, inspect, trace, diff, doctor, bench): + command.add_argument('--format', choices=('text', 'json'), default='text') + return p + + +def doctor_report(): + from .lineage import native_binary + from .services import versions + from .toolchain import find_tool + native = native_binary() + report = {'versions': versions(), 'native': {'available': bool(native)}, + 'tools': {name: find_tool(name) for name in ('iree-compile', 'iree-opt', 'iree-benchmark-module')}, + 'web_assets': (Path(__file__).parent / '_web/index.html').is_file(), + 'supported_target': 'Linux x86-64 CPU', 'host_supported': sys.platform == 'linux' and platform.machine() == 'x86_64'} + if native: + try: + result = subprocess.run([str(native), '--version'], capture_output=True, text=True, timeout=10) + report['native'].update(path=str(native), version=result.stdout.strip(), available=result.returncode == 0, error=result.stderr.strip()) + except (OSError, subprocess.TimeoutExpired) as exc: report['native'].update(available=False, error=str(exc)) + report['ready'] = report['host_supported'] and report['native']['available'] and report['web_assets'] and all(report['tools'].values()) and all(v != 'not installed' for k, v in report['versions'].items() if k != 'compilerlens') + return report + + +def render(value, *, json_output=False, kind=None): + if json_output: + print(json.dumps(value, indent=2, allow_nan=False)) + return + from rich.console import Console + from rich.syntax import Syntax + from rich.table import Table + from rich.tree import Tree + console = Console(highlight=False) + if isinstance(value, str): console.print(value, markup=False); return + if kind == 'ir': + console.print(f"{value['stage']} · lines {value['start_line']}–{value['end_line']}", markup=False) + console.print(Syntax(value['text'], {'llvm': 'llvm', 'asm': 'asm'}.get(value['language'], value['language']), line_numbers=True, start_line=value['start_line'], background_color='default')) + return + if kind == 'summary': + console.print(f"CompilerLens · {value['compilation_id']}", style='bold', markup=False) + console.print(f"{value['stage_count']} stages · {value['evidence_count']} evidence items · lineage: {value['lineage']}") + console.print('Target: ' + json.dumps(value['target']), markup=False) + if value['coverage']: + render([{'Stage': name, 'Ops': c['instructions'], 'Debug': c.get('debug_locations', '—'), + 'Dispatch': c.get('dispatch_resolved', '—'), 'Source': c['source_associated'], + 'Module': c.get('module_associated', '—')} for name, c in value['coverage'].items()]) + for note in value['notes']: console.print('• ' + note, markup=False) + return + if kind == 'architecture': + root = Tree('Model architecture') + nodes = {n['id']: n for n in value.get('nodes', [])} + trees = {} + for n in sorted(nodes.values(), key=lambda n: n.get('depth', 0)): + parent = trees.get(n.get('parent_id'), root) + trees[n['id']] = parent.add(f"{n['path']} ({n['type']}) · {n.get('parameter_count', 0):,} parameters · {n.get('mapping', 'unknown')}") + console.print(root) + console.print(value.get('mapping_note', ''), markup=False) + return + if kind == 'trace' and 'matches' in value: + console.print(value['relationship'], markup=False) + console.print(f"{len(value['matches'])} matching instructions; {len(value['origins'])} source locations") + rows = [{'stage': r['stage'], 'line': r['line'], 'id': r['id'], 'instruction': r.get('text', r.get('opcode', '')), + 'origins': '; '.join(f"torch-input:{o['line']}:{o['column']}" for o in r.get('origins', [])), + 'via': '; '.join(f"{f['file']}:{f['line']}:{f.get('column', 0)}" for f in r.get('frames', []))} for r in value['matches']] + render(rows) + for note in value['notes']: console.print(note, markup=False) + return + if isinstance(value, list) and value and all(isinstance(row, dict) for row in value): + keys = list(value[0]) + if kind == 'ops': keys = [k for k in ('id', 'line', 'name', 'text', 'origins') if k in value[0]] + table = Table(*keys, expand=False) + for row in value: + table.add_row(*(json.dumps(row.get(k), ensure_ascii=False) if isinstance(row.get(k), (dict, list)) else str(row.get(k, '')) for k in keys)) + console.print(table) + else: + console.print_json(json.dumps(value, ensure_ascii=False)) + + +def main(argv=None): + args = parser().parse_args(argv) + try: + command, fmt = args.command, getattr(args, 'format', 'text') + kind = None + if command in ('compile', 'import'): + from .services import capture, import_dump + kwargs = vars(args).copy() + kwargs.pop('command'); kwargs.pop('format') + # Libraries sometimes print progress to stdout; reserve stdout for the result. + with contextlib.redirect_stdout(sys.stderr): + root = (capture if command == 'compile' else import_dump)(**kwargs, progress=lambda label: print(label, file=sys.stderr)) + value = {'run': str(root), 'status': 'complete', 'artifact': str(root / 'artifact.json')} + elif command == 'doctor': value = doctor_report() + elif command == 'view': + from .server import serve + serve(args.run, port=args.port, open_browser=args.open_browser) + return 0 + elif command == 'bench': + from .benchmark import benchmark_run + value = benchmark_run(args.run, workers=args.workers, repetitions=args.repetitions) + else: + from .queries import inspect_report, trace_report, diff_report + root, artifact, lineage = load_run(args.run) + if command == 'inspect': + if args.stage and args.show not in ('evidence', 'ops', 'ir'): raise ValueError('--stage applies to --show evidence, ops or ir.') + if args.lines and args.show not in ('ops', 'ir'): raise ValueError('--lines applies to --show ops or ir.') + if args.module and args.show not in ('ops', 'architecture'): raise ValueError('--module applies to --show ops or architecture.') + if args.show == 'objects': + from .lineage import object_sections + value = object_sections(root) + else: + value = inspect_report(artifact, lineage, show=args.show, stage=args.stage, module=args.module, lines=args.lines) + kind = args.show + elif command == 'trace': + if args.object: + if not args.section or args.address is None or args.line is not None: raise ValueError('--object requires --section and --address (not --line).') + from .lineage import object_lookup + value = object_lookup(root, args.object, args.section, args.address) + else: + if args.section or args.address is not None: raise ValueError('--section and --address require --object.') + value = trace_report(artifact, lineage, op=args.op, module=args.module, source=args.source, from_stage=args.from_stage, line=args.line, to=args.to) + kind = 'trace' + else: value = diff_report(artifact, lineage, args.before, args.after, args.mode) + render(value, json_output=fmt == 'json', kind=kind) + return 0 if command != 'doctor' or value['ready'] else 1 + except (OSError, ValueError, RuntimeError, ImportError, subprocess.SubprocessError) as exc: + print(f'compilerlens: {exc}', file=sys.stderr) + return 1 + except KeyboardInterrupt: + print('compilerlens: interrupted; captured diagnostics are retained.', file=sys.stderr) + return 130 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/compilerlens/examples/__init__.py b/compilerlens/examples/__init__.py new file mode 100644 index 0000000..1c049cb --- /dev/null +++ b/compilerlens/examples/__init__.py @@ -0,0 +1,10 @@ +"""Small offline compiler workloads.""" +"""Packaged workloads; importing the registry does not load PyTorch or models.""" + +EXAMPLES = { + 'matmul': 'compilerlens.examples.matmul', + 'linear_relu': 'compilerlens.examples.linear_relu', + 'mini_transformer': 'compilerlens.examples.mini_transformer', + 'tiny_vit': 'compilerlens.examples.tiny_vit', + 'tiny_clip': 'compilerlens.examples.tiny_clip', +} diff --git a/compilerlens/examples/linear_relu.py b/compilerlens/examples/linear_relu.py new file mode 100644 index 0000000..f671c6f --- /dev/null +++ b/compilerlens/examples/linear_relu.py @@ -0,0 +1,19 @@ +import torch + + +class LinearReluModule(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(128, 128)) + self.bias = torch.nn.Parameter(torch.randn(128)) + + def forward(self, x): + return torch.relu(x @ self.weight + self.bias) + + +def build_module(): + return LinearReluModule() + + +def example_inputs(): + return (torch.randn(128, 128),) diff --git a/compilerlens/examples/matmul.py b/compilerlens/examples/matmul.py new file mode 100644 index 0000000..9c8e89d --- /dev/null +++ b/compilerlens/examples/matmul.py @@ -0,0 +1,14 @@ +import torch + + +class MatmulModule(torch.nn.Module): + def forward(self, a, b): + return torch.matmul(a, b) + + +def build_module(): + return MatmulModule() + + +def example_inputs(): + return (torch.randn(128, 128), torch.randn(128, 128)) diff --git a/compilerlens/examples/mini_transformer.py b/compilerlens/examples/mini_transformer.py new file mode 100644 index 0000000..b8a2205 --- /dev/null +++ b/compilerlens/examples/mini_transformer.py @@ -0,0 +1,30 @@ +import torch + + +class MiniTransformerBlock(torch.nn.Module): + def __init__(self, d_model=64, n_heads=4, d_ff=128): + super().__init__() + self.qkv = torch.nn.Linear(d_model, d_model * 3) + self.out_proj = torch.nn.Linear(d_model, d_model) + self.norm1 = torch.nn.LayerNorm(d_model) + self.ff1 = torch.nn.Linear(d_model, d_ff) + self.ff2 = torch.nn.Linear(d_ff, d_model) + self.norm2 = torch.nn.LayerNorm(d_model) + self.n_heads = n_heads + + def forward(self, x): + qkv = self.qkv(x) + q, k, v = qkv.chunk(3, dim=-1) + attn_scores = torch.softmax((q @ k.transpose(-2, -1)) / (q.shape[-1] ** 0.5), dim=-1) + attn_out = attn_scores @ v + x = self.norm1(x + self.out_proj(attn_out)) + ff_out = self.ff2(torch.relu(self.ff1(x))) + return self.norm2(x + ff_out) + + +def build_module(): + return MiniTransformerBlock() + + +def example_inputs(): + return (torch.randn(16, 64),) diff --git a/compilerlens/examples/tiny_clip.py b/compilerlens/examples/tiny_clip.py new file mode 100644 index 0000000..bed6e83 --- /dev/null +++ b/compilerlens/examples/tiny_clip.py @@ -0,0 +1,33 @@ +"""Offline image-text similarity demo: random weights, no meaningful predictions.""" +import torch +from transformers import CLIPConfig, CLIPModel + +from compilerlens.models.adapters import ADAPTERS, CLIPWrapper, clip_inputs + +PROFILE = {'image_size': [8, 8], 'num_channels': 3, 'vocab_size': 16, + 'bos_token_id': 1, 'eos_token_id': 15} +SEQ_LEN = 4 + + +def build_module(): + text = dict(vocab_size=16, hidden_size=8, intermediate_size=16, + num_hidden_layers=1, num_attention_heads=2, + max_position_embeddings=8, bos_token_id=1, eos_token_id=15, pad_token_id=0) + vision = dict(image_size=8, patch_size=4, num_channels=3, hidden_size=8, + intermediate_size=16, num_hidden_layers=1, num_attention_heads=2) + config = CLIPConfig(text_config=text, vision_config=vision, projection_dim=4) + config._attn_implementation = 'eager' + return CLIPWrapper(CLIPModel(config)).eval() + + +def example_inputs(): + return clip_inputs(PROFILE, SEQ_LEN) + + +def model_info(): + inputs = (torch.empty(1, SEQ_LEN, dtype=torch.int64, device='meta'), + torch.empty(1, 3, 8, 8, device='meta'), + torch.empty(1, 1, SEQ_LEN, SEQ_LEN, device='meta')) + return {'model_id': 'tiny_clip', 'model_type': 'clip', 'weights': 'random', + 'seq_len': SEQ_LEN, 'vocab_size': 16, 'causal': False, + **ADAPTERS['clip'].metadata(None, inputs)} diff --git a/compilerlens/examples/tiny_vit.py b/compilerlens/examples/tiny_vit.py new file mode 100644 index 0000000..6a895b4 --- /dev/null +++ b/compilerlens/examples/tiny_vit.py @@ -0,0 +1,22 @@ +"""Offline vision-encoder demo: random weights and synthetic image tensors.""" +import torch +from transformers import ViTConfig, ViTModel + +from compilerlens.models.adapters import ADAPTERS, ViTWrapper + + +def build_module(): + config = ViTConfig(image_size=8, patch_size=4, num_channels=3, hidden_size=8, + intermediate_size=16, num_hidden_layers=1, num_attention_heads=2) + config._attn_implementation = 'eager' + return ViTWrapper(ViTModel(config)).eval() + + +def example_inputs(): + return (torch.randn(1, 3, 8, 8),) + + +def model_info(): + return {'model_id': 'tiny_vit', 'model_type': 'vit', 'weights': 'random', + 'seq_len': None, 'causal': False, + **ADAPTERS['vit'].metadata(None, (torch.empty(1, 3, 8, 8, device='meta'),))} diff --git a/compilerlens/ingest/__init__.py b/compilerlens/ingest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ingest/architecture.py b/compilerlens/ingest/architecture.py similarity index 97% rename from ingest/architecture.py rename to compilerlens/ingest/architecture.py index 28c2a16..555e9ed 100644 --- a/ingest/architecture.py +++ b/compilerlens/ingest/architecture.py @@ -88,6 +88,8 @@ def _fallback(stages: list, model_info: dict) -> dict: "sequence_length": model_info.get("seq_len"), "hidden_size": None, "layer_count": None, "attention_heads": None, "intermediate_size": None, "vocab_size": model_info.get("vocab_size"), + **{key: model_info[key] for key in ('modalities', 'task', 'input_profile', 'output_names', 'weights') + if key in model_info}, }, "nodes": [root, *children], "edges": edges, } diff --git a/ingest/build.py b/compilerlens/ingest/build.py similarity index 89% rename from ingest/build.py rename to compilerlens/ingest/build.py index 2135cc9..6c3debe 100644 --- a/ingest/build.py +++ b/compilerlens/ingest/build.py @@ -459,12 +459,17 @@ def add(kind: str, label: str, value: str, stage: Stage, detail: str, status: st return evidence, target -def build_artifact(workload: WorkloadSpec, root: Path | None = None) -> Artifact: +def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, + analysis: dict | None = None, lineage: str = 'auto') -> Artifact: """Build one workload's artifact. `root` overrides `workload.dump_root` -- used by Stage 5 to point at a fresh temp directory from a live compile while reusing the same registered stage/pass-track specs. + Reuse supplied capture analysis, or analyze the dumps using the same native pipeline. + Generated reports and lineage.json are written beside the dumps; original IR is retained. """ + if lineage not in ('auto', 'required', 'off'): + raise ValueError('Invalid lineage mode.') dump_root = root if root is not None else Path(workload.dump_root) repo_relative = str(workload.dump_root).rstrip("/") @@ -472,24 +477,50 @@ def build_artifact(workload: WorkloadSpec, root: Path | None = None) -> Artifact if not stages: raise SystemExit(f"no stages found under {dump_root}") + if analysis is None: + from compilerlens.lineage import analyze_files + analysis = analyze_files(dump_root, lineage) + + source_sets = None + if analysis is not None: + from compilerlens.lineage import enrich_stages + from compilerlens.storage import atomic_json + sidecar, source_sets = enrich_stages(dump_root, stages, analysis) + atomic_json(dump_root / "lineage.json", sidecar) + # Evidence, diffs and lineage all read the full op index, so trimming happens only after # they have been derived. Lineage in particular *must* precede it: trimming clears the op # lists for the LLVM, assembly and per-pass stages, which are exactly the stages a hover # most wants to highlight. evidence, target = _build_evidence(stages) diffs = _build_diffs(stages) - lineage = build_lineage(stages) + lineage = build_lineage(stages, source_sets=source_sets) architecture = architecture_builder.build_architecture(dump_root, workload, stages, lineage) + if analysis is not None: + mapped_lines = {line for node in architecture.get('nodes', []) if node.get('mapping') == 'exact' + for line in node.get('source_lines', [])} + for entry in sidecar['stages'].values(): + entry['coverage']['module_associated'] = sum(any(o['line'] in mapped_lines for o in r['origins']) for r in entry['records']) + atomic_json(dump_root / 'lineage.json', sidecar) located = sum(1 for s in stages for op in s.ops if op.source_loc) trim_note = _trim_op_index(stages) source_stage = _find_stage(stages, "pytorch-source") notes = [ - "Operation counts come from a heuristic textual parser, not a real MLIR parser. " - "The IR itself is displayed verbatim and is always authoritative.", + "MLIR/assembly operation counts use textual parsers. Native LLVM counts use LLVM instructions. " + "LLVM display views add ID comments; original dumps are retained.", ] notes.extend(stage_notes) + verification_path = dump_root / 'verification.json' + if verification_path.is_file(): + verification = json.loads(verification_path.read_text()) + notes.append(f"Compiled output verification: {verification['status']}. " + ( + 'Compared with PyTorch on one captured input sample; not a model-quality evaluation.' + if verification['status'] == 'passed' else verification.get('reason', verification.get('error', '')))) + if analysis is not None: + notes.append("Lineage: " + analysis["status"] + ". Native IDs are snapshot-local; assembly links use emitted .file/.loc associations. Source attribution is not instruction identity.") + notes.extend(analysis["notes"]) if trim_note: notes.append(trim_note) if located: @@ -554,8 +585,20 @@ def main() -> None: parser.add_argument("--out-dir", type=Path, help="directory to write .json + index.json (--all mode)") parser.add_argument("--id", default=None, help="compilation id override (ad hoc mode; defaults to dir name)") parser.add_argument("--indent", type=int, default=None, help="pretty-print the JSON") + parser.add_argument("--lineage", choices=('auto', 'required', 'off'), default='auto', + help="Native analysis: auto permits fallback, required fails on missing/failed analysis, off skips it") args = parser.parse_args() + def build(workload, root=None): + try: + artifact = build_artifact(workload, root=root, lineage=args.lineage) + except RuntimeError as exc: + parser.exit(1, f"{workload.id}: {exc}\n") + for note in artifact.notes: + if note.startswith('Lineage:'): + print(f" {workload.id}: {note}", file=sys.stderr) + return artifact + if args.all: if not args.out_dir: raise SystemExit("--all requires --out-dir") @@ -567,7 +610,7 @@ def main() -> None: if not root.is_dir(): print(f" warning: {root} missing, skipping workload {workload_id}", file=sys.stderr) continue - artifact = build_artifact(workload) + artifact = build(workload) artifacts[workload_id] = artifact out_path = args.out_dir / f"{workload_id}.json" @@ -602,7 +645,7 @@ def main() -> None: f"to test its specs against this directory." ) - artifact = build_artifact(workload, root=args.dump_dir) + artifact = build(workload, root=args.dump_dir) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(artifact.to_json(indent=args.indent)) diff --git a/ingest/lineage.py b/compilerlens/ingest/lineage.py similarity index 95% rename from ingest/lineage.py rename to compilerlens/ingest/lineage.py index c1cac84..ea791b4 100644 --- a/ingest/lineage.py +++ b/compilerlens/ingest/lineage.py @@ -227,7 +227,7 @@ def pass_count(stage_id: str) -> int: return hops -def build_lineage(stages: list) -> dict: +def build_lineage(stages: list, *, source_sets: dict | None = None) -> dict: """Index from anchor-stage line number to every operation derived from it. Shape, keyed by line number as a string so it survives JSON: @@ -264,16 +264,20 @@ def build_lineage(stages: list) -> dict: op_names: dict = defaultdict(lambda: defaultdict(int)) for stage in stages: + if source_sets is not None and stage is anchor: + continue # Anchor membership is added once below. for op in stage.ops: - if not op.source_loc: - continue - parsed = _split_loc(op.source_loc) - if parsed is None: - continue - source_line = parsed[0] - hits[source_line][stage.id].append(op.line) - stage_names[source_line][stage.id][op.name] += 1 - op_names[source_line][op.name] += 1 + locations = source_sets.get(op.id, []) if source_sets is not None else ([op.source_loc] if op.source_loc else []) + seen = set() + for location in locations: + parsed = _split_loc(location) + if parsed is None or parsed[0] in seen: + continue + source_line = parsed[0] + seen.add(source_line) + hits[source_line][stage.id].append(op.line) + stage_names[source_line][stage.id][op.name] += 1 + op_names[source_line][op.name] += 1 # The anchor stage's own operations carry no source_loc -- they *are* the source, so nothing # points back at them. Without this, the index has no entry for the anchor stage itself and diff --git a/ingest/llvm_parser.py b/compilerlens/ingest/llvm_parser.py similarity index 100% rename from ingest/llvm_parser.py rename to compilerlens/ingest/llvm_parser.py diff --git a/ingest/mlir_loc.py b/compilerlens/ingest/mlir_loc.py similarity index 85% rename from ingest/mlir_loc.py rename to compilerlens/ingest/mlir_loc.py index c0b30b1..4d849c3 100644 --- a/ingest/mlir_loc.py +++ b/compilerlens/ingest/mlir_loc.py @@ -165,3 +165,28 @@ def strip_locations(text: str) -> str: continue out.append(line) return '\n'.join(out) + + +def source_location_sets_by_line(text: str) -> dict[int, list[str]]: + """All explicit file/line/column leaves in a location, including fused sets.""" + aliases = collect_aliases(text) + output = {} + leaf = re.compile(r'(? WorkloadSpec: model_id = model_info.get("model_id", dump_root.name) params = model_info.get("param_count") param_text = f"{params / 1e6:.1f}M parameters" if params else "unknown size" - kind = "causal decoder" if model_info.get("causal") else "bidirectional encoder" - seq_len = model_info.get("seq_len") + task = model_info.get('task') + profile_text = f"; {task}" if task else '' + if model_info.get('input_profile', {}).get('kind') == 'synthetic': + profile_text += '; synthetic inputs' + if model_info.get('weights') == 'random': + profile_text += '; random weights (compiler demo)' stages: list[StageSpec] = [] @@ -136,10 +140,8 @@ def spec_from_dump_dir(dump_root: Path, model_info: dict) -> WorkloadSpec: "PyTorch Source", PHASE_FRONTEND, "python", - f"{model_id}: a {kind}, {param_text}, exported through iree-turbine's AOT " - f"interface with a sequence length of {seq_len}. The attention mask is built " - f"as a 4D float tensor -- see the comment in the source for why that is " - f"required rather than incidental.", + f"{model_id}: {param_text}. The source used to create this captured workload. " + "Static tensor shapes and actual input values are recorded in the run.", track="source", ) ) @@ -149,41 +151,23 @@ def spec_from_dump_dir(dump_root: Path, model_info: dict) -> WorkloadSpec: stages.append(StageSpec(f"mlir/{filename}", name, title, phase, "mlir", description)) kernels = _dispatch_kernels(llvm_dir) - if kernels: - chosen = kernels[0] - stages.append( - StageSpec( - f"llvm/{chosen.name}", - "dispatch-kernel", - "Dispatch Kernel (MLIR)", - PHASE_LLVM, - "mlir", - f"The device kernel track begins here. This model compiled to " - f"{len(kernels)} dispatch kernels; shown is {_dispatch_scope(chosen)}, the " - f"largest. It branches off the Executable Targets phase rather than " - f"following the VM stage before it.", - track="device", - ) - ) + for i, kernel in enumerate(kernels): + stages.append(StageSpec( + f"llvm/{kernel.name}", "dispatch-kernel" if i == 0 else f"dispatch-kernel-{i}", + f"Dispatch Kernel: {_dispatch_scope(kernel)}", PHASE_LLVM, "mlir", + f"Captured device kernel {i+1} of {len(kernels)}. Separate from the host module track.", + track=f"kernel:{kernel.stem}")) for pattern, name, title, phase, language, description in _LLVM_STAGES: matches = sorted(llvm_dir.glob(pattern)) if llvm_dir.is_dir() else [] - if not matches: - continue - # One linked module covers every dispatch, so take the largest match and say so. - chosen_llvm = max(matches, key=lambda p: p.stat().st_size) - note = "" if len(matches) == 1 else f" ({len(matches)} such files were emitted; this is the largest.)" - stages.append( - StageSpec( - f"llvm/{chosen_llvm.name}", - name, - title, - phase, - language, - description + note, - track="device", - ) - ) + for i, path in enumerate(matches): + # The same module's checkpoints compare together; unrelated modules do not. + module = path.name + for suffix in ('.codegen.ll', '.linked.ll', '.optimized.ll', '.s'): + if module.endswith(suffix): module = module[:-len(suffix)]; break + stages.append(StageSpec(f"llvm/{path.name}", name if i == 0 else f"{name}-{i}", + title if len(matches) == 1 else f"{title}: {module}", + phase, language, description, track=f"device:{module}")) pass_tracks: list[PassTrackSpec] = [] step_a = passes_dir / "passes_stepA_torch_to_iree.txt" @@ -220,7 +204,7 @@ def spec_from_dump_dir(dump_root: Path, model_info: dict) -> WorkloadSpec: return WorkloadSpec( id=dump_root.name, title=model_id, - description=f"{model_id} -- {kind}, {param_text}, compiled from the HuggingFace Hub.", + description=f"{model_id} -- {param_text}{profile_text}; captured compiler stages.", dump_root=str(dump_root), source_entry=model_id, stages=stages, diff --git a/ingest/workloads/linear_relu.py b/compilerlens/ingest/workloads/linear_relu.py similarity index 100% rename from ingest/workloads/linear_relu.py rename to compilerlens/ingest/workloads/linear_relu.py diff --git a/ingest/workloads/matmul.py b/compilerlens/ingest/workloads/matmul.py similarity index 100% rename from ingest/workloads/matmul.py rename to compilerlens/ingest/workloads/matmul.py diff --git a/compilerlens/lineage.py b/compilerlens/lineage.py new file mode 100644 index 0000000..2cee570 --- /dev/null +++ b/compilerlens/lineage.py @@ -0,0 +1,292 @@ +"""Native LLVM reports and conservative source/assembly joins. + +Def-use edges and source attribution are deliberately separate relationships. +""" +from __future__ import annotations + +import json +import os +import re +import shlex +import shutil +import subprocess +from pathlib import Path + +from .storage import atomic_json, digest +from .ingest.mlir_loc import source_location_sets_by_line +from .ingest.llvm_parser import parse_asm, _classify_ll +from .ingest.schema import Operation + +PROTOCOL = 1 +_LOCATION = re.compile(r'^(.*):(\d+):(\d+)$') + + +def native_binary() -> Path | None: + override = os.environ.get('COMPILERLENS_NATIVE') + if override: + path = Path(override).expanduser().resolve() + return path if path.is_file() else None + package = Path(__file__).resolve().parent + for path in (package / '_bin' / 'compilerlens-native', package.parent / 'build/llvm/compilerlens-native'): + if path.is_file() and os.access(path, os.X_OK): + return path + found = shutil.which('compilerlens-native') + return Path(found) if found else None + + +class SourceIndex: + def __init__(self, root: Path): + self.root = root.resolve() + self.files = {} + self.names = {} + self.aliases = {} + self.anchor = self.root / 'mlir/ir_00_torch_input.mlir' + self.anchor_lines = len(self.anchor.read_text().splitlines()) if self.anchor.exists() else 0 + self.anchor_aliases = {str(self.anchor), str(self.root / '_full' / self.anchor.name), self.anchor.name, + 'mlir/' + self.anchor.name, '_full/' + self.anchor.name} + manifest_path = self.root / 'manifest.json' + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text()) + original = manifest.get('files', {}).get(self.anchor.name) + if original: + self.anchor_aliases.add(str(original)) + original_path = Path(original) + if original_path.parent.name == 'mlir': + self.anchor_aliases.add(str(original_path.parent.parent / '_full' / original_path.name)) + self.legacy_anchor = not manifest_path.is_file() + + for path in sorted((self.root / 'llvm').glob('*dispatch_*.mlir')): + self.files[str(path)] = source_location_sets_by_line(path.read_text()) + self.names.setdefault(path.name, []).append(str(path)) + if match := re.search(r'(dispatch_\d+)\.mlir$', path.name): + self.aliases.setdefault(match[1], []).append(str(path)) + + def file_for(self, filename: str, directory: str = '') -> str | None: + full = str(Path(directory) / filename) if directory else filename + if full in self.files: + return full + # Relocated artifacts: only a unique basename in this compilation can match. + name = Path(filename.replace('\\', '/')).name + candidates = self.names.get(name, []) + if not candidates and (match := re.search(r'(dispatch_\d+)\.mlir$', name)): + candidates = self.aliases.get(match[1], []) + return candidates[0] if len(candidates) == 1 else None + + def canonical(self, location: str) -> dict | None: + match = _LOCATION.match(location) + if not match: + return None + filename, line, column = match[1], int(match[2]), int(match[3]) + # Only the captured Torch anchor is eligible; never join arbitrary helper lines. + if (filename not in self.anchor_aliases and not (self.legacy_anchor and Path(filename.replace('\\', '/')).name == self.anchor.name)) or not (1 <= line <= self.anchor_lines): + return None + return {'file': 'mlir/ir_00_torch_input.mlir', 'line': line, 'column': column} + + def resolve_frame(self, frame: dict) -> list[dict]: + if not frame.get('line'): + return [] + file = self.file_for(frame.get('file', ''), frame.get('directory', '')) + if not file: + return [] + origins = [] + for location in self.files[file].get(frame['line'], []): + origin = self.canonical(location) + if origin and origin not in origins: + origins.append(origin) + return origins + + def resolve_frames(self, frames: list[dict]) -> tuple[list[dict], str]: + for index, frame in enumerate(frames): + origins = self.resolve_frame(frame) + if origins: + return origins, 'debug_location' if index == 0 else 'inline_callsite' + return [], 'unresolved' + + +def assembly_locations(text: str) -> dict[int, dict]: + """Location-state machine for LLVM's x86 GNU-style assembly output.""" + files: dict[int, tuple[str, str]] = {} + active = None + locations = {} + for number, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if re.match(r'\.(?:section|pushsection|popsection|previous|text|data|bss|cfi_endproc|size)\b', line): + active = None + if re.match(r'\.type\s+.+,\s*[@%]function', line): + active = None + if line.startswith('.file'): + try: + parts = shlex.split(line, comments=True) + except ValueError: + active = None + continue + if len(parts) >= 3 and parts[1].isdigit(): + # .file N "file" or .file N "directory" "file" [md5 ...] + directory, filename = ('', parts[2]) + if len(parts) >= 4 and parts[3] != 'md5': + directory, filename = parts[2:4] + files[int(parts[1])] = (directory, filename) + elif line.startswith('.loc'): + active = None + parts = line.split() + if len(parts) >= 4 and all(p.isdigit() for p in parts[1:4]): + file_id, source_line, column = map(int, parts[1:4]) + if source_line > 0 and file_id in files: + directory, filename = files[file_id] + active = {'file': filename, 'directory': directory, 'line': source_line, 'column': column} + if 'discriminator' in parts: + at = parts.index('discriminator') + 1 + if at < len(parts) and parts[at].isdigit(): active['discriminator'] = int(parts[at]) + elif active and raw[:1].isspace() and line and not line.startswith(('.', '#', '//')) and not line.endswith(':'): + locations[number] = dict(active) + return locations + + +def analyze_files(root: Path, mode: str = 'auto', progress=None) -> dict: + reports = {} + if mode == 'off': + return {'status': 'off', 'reports': reports, 'notes': ['Additional lineage was disabled.']} + executable = native_binary() + if executable is None: + if mode == 'required': + raise RuntimeError('Native analyzer unavailable. Install a native-enabled wheel or set COMPILERLENS_NATIVE.') + return {'status': 'fallback', 'reports': reports, 'notes': ['Native analyzer unavailable; metadata/assembly fallback used.']} + errors = [] + native_dir = root / 'native' + native_dir.mkdir(exist_ok=True) + for path in sorted((root / 'llvm').glob('*.ll')): + if progress: progress(f'Native lineage: {path.name}') + report_path = native_dir / (path.name + '.json') + view_path = native_dir / (path.name + '.annotated.ll') + command = [str(executable), '--input', str(path), '--output', str(report_path), '--annotated-ir', str(view_path)] + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=180) + except (OSError, subprocess.TimeoutExpired) as exc: + if mode == 'required': raise RuntimeError(f'Native analysis failed for {path.name}: {exc}') from exc + errors.append(f'{path.name}: {exc}') + continue + if result.returncode: + message = f'{path.name}: {result.stderr.strip()[:1500]}' + if mode == 'required': raise RuntimeError(message) + errors.append(message) + continue + report = json.loads(report_path.read_text()) + if report.get('schema_version') != PROTOCOL: + raise RuntimeError('Native analyzer protocol mismatch') + reports[path.name] = {'report': report, 'report_path': str(report_path.relative_to(root)), + 'view_path': str(view_path.relative_to(root)), 'input_sha256': digest(path)} + if not reports and mode == 'required': raise RuntimeError('No LLVM IR available for required native analysis.') + return {'status': 'native' if reports and not errors else 'partial' if reports else 'fallback', + 'reports': reports, 'notes': errors, 'binary': str(executable)} + + +def enrich_stages(root, stages, analysis): + """Return full precision sidecar and explicit origins for the legacy UI contract.""" + index = SourceIndex(root) + sidecar = {'schema_version': PROTOCOL, 'status': analysis['status'], 'notes': analysis['notes'], 'stages': {}} + source_sets = {} + for stage in stages: + records = [] + native = analysis['reports'].get(Path(stage.source_path).name) if stage.language == 'llvm' else None + if native: + stage.text = (root / native['view_path']).read_text() + lines = stage.text.splitlines() + markers = {} + pending = None + for number, line in enumerate(lines, 1): + if match := re.search(r'; compilerlens\.id=(\S+)', line): + pending = match[1] + elif pending and line.strip() and not line.lstrip().startswith(';'): + markers[pending] = number + pending = None + ops = [] + for record in native['report']['instructions']: + if record['id'] not in markers: raise RuntimeError('Native annotation/record mismatch') + number = markers[record['id']] + origins, relationship = index.resolve_frames(record['frames']) + op = Operation(id=f'{stage.id}:op{number}', stage_id=stage.id, line=number, + name=record['opcode'], dialect=_classify_ll(record['opcode']), + types=[record['type']], operands=record['operands'], text=lines[number-1].strip()[:400]) + ops.append(op) + records.append({**record, 'line': number, 'origins': origins, 'relationship': relationship}) + stage.ops = ops + stage.description += ' Native LLVM annotated view; original dump retained. IDs are snapshot-local.' + native_meta = {key: native[key] for key in ('report_path', 'view_path', 'input_sha256')} + native_meta['llvm_version'] = native['report']['llvm_version'] + else: + native_meta = None + asm = assembly_locations(stage.text) if stage.language == 'asm' and analysis['status'] != 'off' else {} + mlir = source_location_sets_by_line(stage.text) if stage.language == 'mlir' else {} + for op in stage.ops: + frames = [asm[op.line]] if op.line in asm else [] + if frames: + origins, relationship = index.resolve_frames(frames) + if origins: relationship = 'assembly_location' + else: + locations = mlir.get(op.line, [op.source_loc] if op.source_loc and analysis['status'] != 'off' else []) + origins = [o for loc in locations if (o := index.canonical(loc))] + relationship = 'compiler_location' if origins else 'unresolved' + if stage.name == 'torch-input': + text_line = stage.text.splitlines()[op.line - 1] + column = max(0, text_line.find(op.name)) + 1 + origins = [{'file': 'mlir/ir_00_torch_input.mlir', 'line': op.line, 'column': column}] + relationship = 'source_anchor' + records.append({'id': op.id, 'line': op.line, 'opcode': op.name, + 'origins': origins, 'frames': frames, 'relationship': relationship}) + by_line = {r['line']: r for r in records} + for op in stage.ops: + origins = by_line[op.line]['origins'] + source_sets[op.id] = [f"{o['file']}:{o['line']}:{o['column']}" for o in origins] + op.source_loc = source_sets[op.id][0] if len(origins) == 1 else None + stage.line_count = len(stage.text.splitlines()) + stage.byte_size = len(stage.text.encode()) + stage.op_count = len(stage.ops) + from .ingest.llvm_parser import histogram + stage.op_histogram = histogram([{'dialect': o.dialect} for o in stage.ops], 'dialect') + if records: + counts = {'instructions': len(records), + 'debug_locations': sum(any(f.get('line') for f in r.get('frames', [])) for r in records), + 'dispatch_resolved': sum(any(index.file_for(f.get('file', ''), f.get('directory', '')) and f.get('line') for f in r.get('frames', [])) for r in records), + 'source_associated': sum(bool(r['origins']) for r in records)} + reasons = {} + for r in records: + reason = ('resolved' if r['origins'] else 'no_location' if not r.get('frames') else + 'line_zero' if not any(f.get('line') for f in r['frames']) else + 'dispatch_without_anchor' if any(index.file_for(f.get('file', ''), f.get('directory', '')) for f in r['frames']) else + 'external_or_ambiguous_file') + r['resolution'] = reason + reasons[reason] = reasons.get(reason, 0) + 1 + entry = {'name': stage.name, 'language': stage.language, + 'source_path': stage.source_path, 'native': native_meta, 'records': records, + 'coverage': counts, 'gaps': reasons} + if native: entry['functions'] = native['report']['functions'] + sidecar['stages'][stage.id] = entry + return sidecar, source_sets + + +def object_lookup(root: Path, object_id: str, section: str, address: str): + binary = native_binary() + if not binary: raise RuntimeError('Object lookup requires the native analyzer.') + path = (root / object_id).resolve() + if not path.is_relative_to(root.resolve()) or not path.is_file(): + raise ValueError('Object must be a file within this run.') + result = subprocess.run([str(binary), '--object', str(path), '--section', section, '--address', address], + capture_output=True, text=True, timeout=60) + if result.returncode: raise RuntimeError(result.stderr.strip()) + report = json.loads(result.stdout) + index = SourceIndex(root) + report['origins'], report['relationship'] = index.resolve_frames(report['frames']) + return report + + +def object_sections(root: Path): + binary = native_binary() + if not binary: raise RuntimeError('Object section listing requires the native analyzer.') + reports = [] + for path in sorted((root / 'llvm').glob('*')): + if path.suffix not in ('.o', '.so'): continue + result = subprocess.run([str(binary), '--object', str(path), '--list-sections'], capture_output=True, text=True, timeout=60) + if result.returncode: raise RuntimeError(result.stderr.strip()) + report = json.loads(result.stdout) + reports.extend({'object': str(path.relative_to(root)), **s} for s in report['sections'] if s['executable'] and s['size']) + return reports diff --git a/compilerlens/models/__init__.py b/compilerlens/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/compilerlens/models/adapters.py b/compilerlens/models/adapters.py new file mode 100644 index 0000000..d662fdc --- /dev/null +++ b/compilerlens/models/adapters.py @@ -0,0 +1,119 @@ +"""Model-family contracts for fixed-shape, CPU/float32 compiler workloads. + +These adapters compile tensor forward passes, not tokenization, image decoding, +classification postprocessing, or generation. No processor packages are required. +""" +from __future__ import annotations + +from dataclasses import dataclass + +import torch + + +class ViTWrapper(torch.nn.Module): + def __init__(self, hf_model): + super().__init__() + self.hf_model = hf_model + + def forward(self, pixel_values): + return self.hf_model(pixel_values=pixel_values).last_hidden_state + + +class CLIPWrapper(torch.nn.Module): + def __init__(self, hf_model): + super().__init__() + self.hf_model = hf_model + + def forward(self, input_ids, pixel_values, attention_mask): + output = self.hf_model(input_ids=input_ids, pixel_values=pixel_values, + attention_mask=attention_mask, return_loss=False) + return output.image_embeds, output.text_embeds, output.logits_per_image + + +def vision_inputs(config: dict) -> tuple: + return (torch.randn(1, config['num_channels'], *config['image_size']),) + + +def clip_inputs(config: dict, seq_len: int) -> tuple: + from .hf_wrapper import build_attention_mask + + # Include a real end-of-text marker; CLIP pools at EOS. Legacy CLIP configs + # with eos_token_id=2 instead pool at the largest token ID in the sequence. + eos = config['eos_token_id'] + pool_token = config['vocab_size'] - 1 if eos == 2 else eos + # Exclude EOS from the random interior to keep the selected position stable. + ids = torch.randint(0, config['vocab_size'] - 1, (1, seq_len)) + ids = ids + (ids >= pool_token).to(ids.dtype) + ids[0, 0] = config['bos_token_id'] + ids[0, -1] = pool_token + return ids, vision_inputs(config)[0], build_attention_mask(seq_len, causal=True) + + +@dataclass(frozen=True) +class ModelAdapter: + name: str + model_class: str + modalities: tuple[str, ...] + task: str + input_names: tuple[str, ...] + output_names: tuple[str, ...] + + def prepare(self, detected, hf_model): + if self.name == 'vit': + return ViTWrapper(hf_model), vision_inputs(detected.input_config) + if self.name == 'clip': + return CLIPWrapper(hf_model), clip_inputs(detected.input_config, detected.seq_len) + from .hf_wrapper import HFWrapper, build_example_inputs + return HFWrapper(hf_model, detected.output_attr), build_example_inputs(detected) + + def metadata(self, detected, inputs) -> dict: + names = self.output_names or (detected.output_attr,) + return { + 'adapter': self.name, + 'modalities': list(self.modalities), + 'task': self.task if self.name != 'text' else ( + 'causal-lm-forward' if detected.causal else 'text-encoder'), + 'input_profile': { + 'kind': 'synthetic', 'batch_size': 1, + 'preprocessing': 'outside compiled graph; synthetic tensors, not real media', + 'inputs': [{'name': name, 'shape': list(tensor.shape), + 'dtype': str(tensor.dtype).removeprefix('torch.')} + for name, tensor in zip(self.input_names, inputs)], + }, + 'output_names': list(names), + } + + +ADAPTERS = { + 'text': ModelAdapter('text', 'AutoModel', ('text',), 'text-encoder', + ('input_ids', 'attention_mask'), ()), + 'vit': ModelAdapter('vit', 'ViTModel', ('image',), 'image-encoder', + ('pixel_values',), ('last_hidden_state',)), + 'clip': ModelAdapter('clip', 'CLIPModel', ('image', 'text'), 'image-text-similarity', + ('input_ids', 'pixel_values', 'attention_mask'), + ('image_embeds', 'text_embeds', 'logits_per_image')), +} + + +def adapter_for(detected) -> ModelAdapter: + return ADAPTERS[detected.adapter] + + +def check_parameter_budget(detected, max_parameters: int | None) -> None: + """Reject oversized new model families before downloading their weights. + + Meta tensors retain shapes but allocate no parameter storage. This is a parameter + bound, not an estimate of peak compiler memory or disk usage. + """ + if max_parameters is None or detected.adapter == 'text': + return + import transformers + config_class = transformers.ViTConfig if detected.adapter == 'vit' else transformers.CLIPConfig + config = config_class.from_dict(detected.model_config) + config._attn_implementation = 'eager' + with torch.device('meta'): + kwargs = {'add_pooling_layer': False} if detected.adapter == 'vit' else {} + model = getattr(transformers, adapter_for(detected).model_class)(config, **kwargs) + count = sum(parameter.numel() for parameter in model.parameters()) + if count > max_parameters: + raise ValueError(f'{count} parameters exceeds the {max_parameters} parameter interactive limit (before weight download)') diff --git a/models/architecture.py b/compilerlens/models/architecture.py similarity index 97% rename from models/architecture.py rename to compilerlens/models/architecture.py index 2f88546..d2ad39a 100644 --- a/models/architecture.py +++ b/compilerlens/models/architecture.py @@ -1,6 +1,6 @@ """Capture a model's module hierarchy and connect it to exported Torch operations. -The important property is provenance, not visual similarity. We use the nn_module_stack +The important property is lineage, not visual similarity. We use the nn_module_stack metadata emitted by torch.export and only attach Torch-MLIR line numbers when the complete ordered operation stream agrees with the exact ExportedProgram passed to Turbine. """ @@ -15,7 +15,7 @@ import torch -from ingest.mlir_parser import parse_operations +from compilerlens.ingest.mlir_parser import parse_operations def export_program(module: torch.nn.Module, example_args: tuple) -> torch.export.ExportedProgram: @@ -153,6 +153,8 @@ def first(*names: str): "attention_heads": first("num_attention_heads", "n_head"), "intermediate_size": first("intermediate_size", "n_inner", "ffn_dim"), "vocab_size": first("vocab_size") or model_info.get("vocab_size"), + **{key: model_info[key] for key in ('modalities', 'task', 'input_profile', 'output_names', 'weights') + if key in model_info}, } @@ -325,8 +327,8 @@ def main() -> None: raise SystemExit(f"missing {info_path}") from iree.turbine import aot - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap info = json.loads(info_path.read_text()) detected = detect( diff --git a/models/detect.py b/compilerlens/models/detect.py similarity index 58% rename from models/detect.py rename to compilerlens/models/detect.py index a2fe497..0327000 100644 --- a/models/detect.py +++ b/compilerlens/models/detect.py @@ -1,22 +1,30 @@ -"""Work out how to load and trace a HuggingFace model from nothing but its id. - -The point of this module is that adding a model costs no code: `compile_hf_model.py -prajjwal1/bert-tiny` should work without anyone registering anything. What makes that -awkward is that Hub configs are not uniform -- `prajjwal1/bert-tiny`, one of the models we -most want to demo, has neither `model_type` nor `architectures` in its config.json, and -`AutoConfig.from_pretrained` raises ValueError on it. So detection is a chain that falls -back to sniffing the raw config keys, and fails loudly rather than guessing when it cannot -tell. +"""Resolve a Hub revision and select a supported model-family input contract. + +Text detection retains legacy config-key inference for older BERT/GPT checkpoints. +Vision and multimodal models require explicit adapters, not guessed text signatures. """ from __future__ import annotations import json -from dataclasses import dataclass +import re +from dataclasses import dataclass, field from pathlib import Path from huggingface_hub import HfApi, hf_hub_download + +def normalize_model_id(value: str) -> str: + """Accept an ID or Hub repository URL; explicit revision arguments take precedence.""" + value = value.strip().rstrip('/') + prefix = 'https://huggingface.co/' + if value.startswith(prefix): + parts = value[len(prefix):].split('/') + if len(parts) > 1 and parts[1] not in {'blob', 'commit', 'discussions', 'resolve', 'tree'}: + return '/'.join(parts[:2]) + return parts[0] + return value + # Architecture suffixes that mean "generates tokens left to right", which is what decides # whether the attention mask we build is causal. _CAUSAL_ARCH_SUFFIXES = ("ForCausalLM", "LMHeadModel") @@ -53,6 +61,11 @@ class DetectedModel: model_type: str # for the record, e.g. "bert"; "unknown" when only sniffing worked detected_via: str # "autoconfig" | "config-keys" -- reported so the user sees which path ran + offline: bool = False + adapter: str = 'text' + input_config: dict = field(default_factory=dict) + model_config: dict = field(default_factory=dict, repr=False) + @property def slug(self) -> str: """Filesystem/URL-safe id: prajjwal1/bert-tiny -> prajjwal1_bert-tiny.""" @@ -70,13 +83,24 @@ def load_model(self, *, dtype=None): from transformers import AutoConfig, AutoModel, AutoModelForCausalLM model_class = AutoModelForCausalLM if self.causal else AutoModel - load_kwargs = {"revision": self.revision} + if self.adapter != 'text': + import transformers + from .adapters import adapter_for + model_class = getattr(transformers, adapter_for(self).model_class) + load_kwargs = {"revision": self.revision, "local_files_only": self.offline, "trust_remote_code": False} if dtype is not None: # Some checkpoints are stored as bfloat16. Turbine's current FX importer lifts # constants through NumPy, which cannot consume a torch.bfloat16 tensor. Loading # directly in float32 avoids both that bridge failure and a temporary bf16->f32 # copy of the complete model after loading. load_kwargs["dtype"] = dtype + if self.adapter != 'text': + # Use exportable, explicit attention rather than backend-specific kernels. + load_kwargs['attn_implementation'] = 'eager' + if self.adapter == 'vit': + # Capture encoder states only. Classification checkpoints do not contain + # pooler weights; constructing one would silently add random parameters. + load_kwargs['add_pooling_layer'] = False if self.detected_via == "autoconfig": return model_class.from_pretrained(self.model_id, **load_kwargs).eval() @@ -92,7 +116,7 @@ def _config_overrides(self) -> dict: back to the family default, which is exactly what a config lacking model_type is relying on anyway. """ - raw = _raw_config(self.model_id, self.revision) + raw = _raw_config(self.model_id, self.revision, offline=self.offline) return {k: v for k, v in raw.items() if k not in {"model_type", "architectures"}} @@ -148,27 +172,31 @@ def _valid(sha: str) -> bool: return None -def _resolve_revision(model_id: str, revision: str | None) -> str: - if revision: - return revision +def _resolve_revision(model_id: str, revision: str | None, *, offline=False) -> str: + ref = revision or 'main' + if re.fullmatch(r'[0-9a-fA-F]{40}', ref): + return ref + if not offline: + try: + return HfApi().model_info(model_id, revision=ref).sha + except Exception: + pass try: - return HfApi().model_info(model_id).sha - except Exception as exc: # network, offline mode, 404, gated repo, rate limit - cached = _cached_revision(model_id) - if cached: - return cached - raise UnsupportedModelError( - f"could not resolve a revision for '{model_id}': {exc}\n" - f" If the model is cached, pass --revision to skip the Hub lookup." - ) from exc + path = Path(hf_hub_download(model_id, 'config.json', revision=ref, local_files_only=True)) + # Hub returns the snapshot path; resolving the symlink would lose its commit. + sha = path.parent.name + if re.fullmatch(r'[0-9a-fA-F]{40}', sha): return sha + except Exception: + pass + raise UnsupportedModelError(f"Could not resolve '{model_id}' revision '{ref}' to a commit. Download that revision first or pass a cached commit SHA.") -def _raw_config(model_id: str, revision: str) -> dict: +def _raw_config(model_id: str, revision: str, *, offline=False) -> dict: try: - path = hf_hub_download(model_id, "config.json", revision=revision) + path = hf_hub_download(model_id, "config.json", revision=revision, local_files_only=offline) except Exception as exc: - raise UnsupportedModelError(f"'{model_id}' has no downloadable config.json: {exc}") from exc - return json.loads(open(path).read()) + raise UnsupportedModelError(f"'{model_id}' has no available config.json: {exc}") from exc + return json.loads(Path(path).read_text()) def _causal_from(model_type: str, architectures: list[str] | None, is_decoder) -> bool: @@ -180,14 +208,17 @@ def _causal_from(model_type: str, architectures: list[str] | None, is_decoder) - return bool(is_decoder) -def detect(model_id: str, revision: str | None = None, seq_len: int = 32) -> DetectedModel: +def detect(model_id: str, revision: str | None = None, seq_len: int = 32, *, offline: bool = False) -> DetectedModel: """Determine how to load and trace `model_id`. Tries transformers' own AutoConfig first, then falls back to reading config.json directly. Raises UnsupportedModelError if neither identifies the model. """ - resolved = _resolve_revision(model_id, revision) - config = _raw_config(model_id, resolved) + model_id = normalize_model_id(model_id) + if seq_len <= 0: + raise ValueError('Sequence length must be positive.') + resolved = _resolve_revision(model_id, revision, offline=offline) + config = _raw_config(model_id, resolved, offline=offline) model_type = config.get("model_type") architectures = config.get("architectures") @@ -199,7 +230,7 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32) -> Det try: from transformers import AutoConfig - parsed = AutoConfig.from_pretrained(model_id, revision=resolved) + parsed = AutoConfig.from_pretrained(model_id, revision=resolved, local_files_only=offline, trust_remote_code=False) model_type = parsed.model_type architectures = getattr(parsed, "architectures", None) or architectures detected_via = "autoconfig" @@ -207,11 +238,27 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32) -> Det # Fall through to the raw config -- AutoConfig is a nicety, not a requirement. pass + if model_type in ('vit', 'clip'): + profile = _vision_profile(config, model_type, seq_len) + return DetectedModel( + model_id=model_id, revision=resolved, causal=False, + vocab_size=profile.get('vocab_size', 0), seq_len=seq_len, + output_attr='last_hidden_state', model_type=model_type, + detected_via=detected_via, offline=offline, adapter=model_type, + input_config=profile, model_config=config, + ) + + if any(key in config for key in ('vision_config', 'audio_config', 'text_config')): + raise UnsupportedModelError( + f"'{model_id}' has a composite configuration without a supported input adapter. " + 'Only CLIP image-text models are included in the initial multimodal support.') + vocab_size = config.get("vocab_size") if not vocab_size: raise UnsupportedModelError( f"'{model_id}' config.json has no vocab_size, so we cannot build example input " - f"ids for it. Only text models taking input_ids are supported." + f"ids for it. Supported adapters: text encoders/causal LMs, ViT, and CLIP. " + f"Other vision, audio, and multimodal families are not supported yet." ) if (model_type or "") in _SEQ2SEQ_MODEL_TYPES or any( @@ -248,4 +295,43 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32) -> Det output_attr="logits" if causal else "last_hidden_state", model_type=model_type or "unknown", detected_via=detected_via, + offline=offline, ) + + +def _vision_profile(config: dict, family: str, seq_len: int) -> dict: + """Validate static shapes before allocating tensors or downloading weights.""" + from transformers import CLIPConfig, ViTConfig + parsed = CLIPConfig.from_dict(config) if family == 'clip' else ViTConfig.from_dict(config) + vision = parsed.vision_config if family == 'clip' else parsed + size = vision.image_size + size = [size, size] if isinstance(size, int) else size + patch = vision.patch_size + patch = [patch, patch] if isinstance(patch, int) else patch + if (not isinstance(size, (tuple, list)) or not isinstance(patch, (tuple, list)) + or len(size) != 2 or len(patch) != 2 or any( + not isinstance(v, int) or v <= 0 for v in (*size, *patch)) + or any(s % p for s, p in zip(size, patch))): + raise UnsupportedModelError('Image size must be positive and divisible by the patch size.') + # Initial bounded profile: no arbitrary-resolution or positional interpolation. + if max(size) > 512 or (size[0] // patch[0]) * (size[1] // patch[1]) > 1024: + raise UnsupportedModelError('Initial vision profiles allow at most 512 pixels per side and 1024 patches.') + channels = vision.num_channels + if not isinstance(channels, int) or not 1 <= channels <= 4: + raise UnsupportedModelError('Initial vision profiles support 1–4 input channels.') + result = {'image_size': size, 'num_channels': channels} + if family == 'clip': + if not isinstance(vision.image_size, int) or not isinstance(vision.patch_size, int): + raise UnsupportedModelError('CLIP requires a square image with scalar image_size and patch_size.') + text = parsed.text_config + if not 2 <= seq_len <= text.max_position_embeddings: + raise UnsupportedModelError(f'CLIP sequence length must be between 2 and {text.max_position_embeddings}.') + if text.vocab_size < 3 or any(not isinstance(t, int) or not 0 <= t < text.vocab_size + for t in (text.bos_token_id, text.eos_token_id)): + raise UnsupportedModelError('CLIP requires valid BOS/EOS token IDs within its vocabulary.') + pool_token = text.vocab_size - 1 if text.eos_token_id == 2 else text.eos_token_id + if text.bos_token_id == pool_token: + raise UnsupportedModelError('CLIP synthetic profiles require distinct BOS and pooling token IDs.') + result.update(vocab_size=text.vocab_size, bos_token_id=text.bos_token_id, + eos_token_id=text.eos_token_id) + return result diff --git a/models/hf_wrapper.py b/compilerlens/models/hf_wrapper.py similarity index 73% rename from models/hf_wrapper.py rename to compilerlens/models/hf_wrapper.py index 66d1580..1212c99 100644 --- a/models/hf_wrapper.py +++ b/compilerlens/models/hf_wrapper.py @@ -25,7 +25,7 @@ import torch -from models.detect import DetectedModel +from compilerlens.models.detect import DetectedModel class HFWrapper(torch.nn.Module): @@ -51,7 +51,7 @@ def build_example_inputs(detected: DetectedModel) -> tuple[torch.Tensor, torch.T return input_ids, build_attention_mask(detected.seq_len, detected.causal) -def wrap(detected: DetectedModel) -> tuple[HFWrapper, tuple, dict]: +def wrap(detected: DetectedModel, *, max_parameters: int | None = None) -> tuple[torch.nn.Module, tuple, dict]: """Load, wrap, and build inputs for a detected model. Returns (module, example_inputs, model_info) where model_info goes into the manifest and @@ -60,29 +60,56 @@ def wrap(detected: DetectedModel) -> tuple[HFWrapper, tuple, dict]: # Export in float32 even when Hub weights are stored as bfloat16. Turbine currently # materializes parameter constants through NumPy, whose PyTorch bridge rejects bf16. # Loading directly in f32 avoids that importer failure (at the cost of larger memory use). + from .adapters import adapter_for, check_parameter_budget + check_parameter_budget(detected, max_parameters) hf_model = detected.load_model(dtype=torch.float32) - module = HFWrapper(hf_model, detected.output_attr) - example_inputs = build_example_inputs(detected) + adapter = adapter_for(detected) + module, example_inputs = adapter.prepare(detected, hf_model) model_info = { "model_id": detected.model_id, "revision": detected.revision, "model_type": detected.model_type, "causal": detected.causal, - "seq_len": detected.seq_len, - "vocab_size": detected.vocab_size, + "seq_len": detected.seq_len if detected.adapter != 'vit' else None, + "vocab_size": detected.vocab_size or None, "param_count": sum(p.numel() for p in hf_model.parameters()), "compile_dtype": "float32", "detected_via": detected.detected_via, + **adapter.metadata(detected, example_inputs), } return module, example_inputs, model_info -def wrapper_source(detected: DetectedModel) -> str: +def wrapper_source(detected: DetectedModel, seed: int = 0) -> str: """The equivalent standalone PyTorch program, for the artifact's source stage. The frontend shows this as the "PyTorch Source" stage, so it has to be something a reader could actually run -- not a description of what we did. """ + if detected.adapter != 'text': + from .adapters import adapter_for + adapter = adapter_for(detected) + wrapper = 'ViTWrapper' if detected.adapter == 'vit' else 'CLIPWrapper' + inputs = (f'vision_inputs({detected.input_config!r})' if detected.adapter == 'vit' + else f'clip_inputs({detected.input_config!r}, {detected.seq_len})') + return f'''import torch +import iree.turbine.aot as aot +from transformers import {adapter.model_class} +from compilerlens.models.adapters import {wrapper}, vision_inputs, clip_inputs + +# {adapter.task}: synthetic tensor inputs; preprocessing is outside this graph. +# Outputs: {', '.join(adapter.output_names)}. No generation or postprocessing. +torch.manual_seed({seed}) +model = {adapter.model_class}.from_pretrained( + {detected.model_id!r}, revision={detected.revision!r}, + local_files_only={detected.offline!r}, trust_remote_code=False, + dtype=torch.float32, attn_implementation="eager", + {"add_pooling_layer=False," if detected.adapter == 'vit' else ""} +).eval() +module = {wrapper}(model).eval() +inputs = {inputs} +export_output = aot.export(module, *inputs) +''' model_class = "AutoModelForCausalLM" if detected.causal else "AutoModel" if detected.causal: mask_expr = ( @@ -98,6 +125,7 @@ def wrapper_source(detected: DetectedModel) -> str: # {detected.model_id} @ {detected.revision[:12]} # {detected.model_type}, {"causal decoder" if detected.causal else "bidirectional encoder"} +torch.manual_seed({seed}) model = {model_class}.from_pretrained( "{detected.model_id}", revision="{detected.revision}", diff --git a/models/prefetch.py b/compilerlens/models/prefetch.py similarity index 94% rename from models/prefetch.py rename to compilerlens/models/prefetch.py index 7bb97df..ce91109 100644 --- a/models/prefetch.py +++ b/compilerlens/models/prefetch.py @@ -10,7 +10,7 @@ from huggingface_hub import snapshot_download -from models.detect import detect +from compilerlens.models.detect import detect def prefetch(model_id: str) -> None: diff --git a/compilerlens/models/verification.py b/compilerlens/models/verification.py new file mode 100644 index 0000000..482c3e2 --- /dev/null +++ b/compilerlens/models/verification.py @@ -0,0 +1,54 @@ +"""Numerical smoke validation of a compiled forward pass on its capture inputs.""" +from pathlib import Path + +from compilerlens.storage import atomic_json + +# Float32 transformer reductions differ between PyTorch and fused CPU codegen. +# Calibrated on pretrained ViT-tiny against float64 over three input samples. +# Keep a small absolute floor so near-zero outputs cannot hide large errors. +FLOAT32_RTOL = 1e-3 +FLOAT32_ATOL = 1e-4 + + +def verify_forward(vmfb: Path, module, inputs: tuple, output_names: list[str], + report_path: Path) -> dict: + """Write a report even on failure; never treat matching shapes as correctness. + + This checks one synthetic sample, not model quality or all possible inputs. + Call only for executables targeting the current host (or generic CPU). + """ + import numpy as np + import torch + import iree.runtime as runtime + + report = {'status': 'failed', 'rtol': FLOAT32_RTOL, 'atol': FLOAT32_ATOL, 'outputs': [], + 'scope': 'PyTorch vs compiled forward on the captured input tensors'} + try: + with torch.no_grad(): + expected = module(*inputs) + expected = expected if isinstance(expected, (tuple, list)) else (expected,) + vm = runtime.load_vm_flatbuffer(Path(vmfb).read_bytes(), driver='local-sync') + actual = vm.main(*(tensor.detach().cpu().numpy() for tensor in inputs)) + actual = actual if isinstance(actual, (tuple, list)) else (actual,) + if len(actual) != len(expected) or len(expected) != len(output_names): + raise ValueError('Output count differs from the adapter contract.') + for name, value, reference in zip(output_names, actual, expected): + value = np.array(value.to_host(), copy=True) + reference = reference.detach().cpu().numpy() + if value.shape != reference.shape or value.dtype != reference.dtype: + raise ValueError(f'{name}: output shape or dtype differs from PyTorch.') + if not np.isfinite(value).all() or not np.isfinite(reference).all(): + raise ValueError(f'{name}: non-finite output cannot be verified.') + difference = np.abs(value.astype(np.float64) - reference.astype(np.float64)) + error = float(np.max(difference)) if value.size else 0.0 + report['outputs'].append({'name': name, 'shape': list(value.shape), + 'dtype': str(value.dtype), 'max_abs_error': error}) + np.testing.assert_allclose(value, reference, rtol=report['rtol'], atol=report['atol'], + err_msg=name, equal_nan=False) + report['status'] = 'passed' + except Exception as exc: + report['error'] = f'{type(exc).__name__}: {exc}' + raise RuntimeError(f'Compiled output verification failed: {exc}') from exc + finally: + atomic_json(report_path, report) + return report diff --git a/compilerlens/queries.py b/compilerlens/queries.py new file mode 100644 index 0000000..cbff9db --- /dev/null +++ b/compilerlens/queries.py @@ -0,0 +1,129 @@ +"""Queries over saved runs. No compilation, network calls, or tensor imports.""" +from __future__ import annotations + +import difflib +from collections import Counter + + +def stage_for(artifact, selector): + matches = [s for s in artifact['stages'] if selector in (s['id'], s['name'])] + if len(matches) != 1: raise ValueError(f'Unknown or ambiguous stage {selector!r}; inspect --show stages lists valid IDs.') + return matches[0] + + +def module_for(artifact, path): + matches = [n for n in artifact.get('architecture', {}).get('nodes', []) if path in (n['id'], n.get('path'))] + if len(matches) != 1: raise ValueError(f'Unknown module {path!r}; inspect --show architecture lists valid paths.') + return matches[0] + + +def operations(stage, lineage): + saved = lineage.get('stages', {}).get(stage['id'], {}) + if saved.get('records'): + lines = stage['text'].splitlines() + return [{**r, 'id': r['id'] if r['id'].startswith(stage['id'] + ':') else f"{stage['id']}:{r['id']}", + 'native_id': r['id'] if saved.get('native') else None, + 'text': lines[r['line']-1] if r['line'] <= len(lines) else '', + 'name': r['opcode']} for r in saved['records']] + if stage['ops']: return stage['ops'] + from .ingest.llvm_parser import parse_llvm_ir, parse_asm + from .ingest.mlir_parser import parse_operations + parser = {'llvm': parse_llvm_ir, 'asm': parse_asm, 'mlir': parse_operations}.get(stage['language']) + if not parser: return [] + # Legacy artifacts may have trimmed indices; reconstruct display operations only. + parsed = parser(stage['text'], stage['id']) + return [{**op, 'id': f"{stage['id']}:op{op['line']}"} for op in parsed] + + +def inspect_report(artifact, lineage, *, show='summary', stage=None, module=None, lines=None): + selected = stage_for(artifact, stage) if stage else None + node = module_for(artifact, module) if module else None + source_lines = set(node.get('source_lines', [])) if node else None + if show == 'summary': + return {'compilation_id': artifact['compilation_id'], 'target': artifact['target'], + 'stage_count': len(artifact['stages']), 'evidence_count': len(artifact['evidence']), + 'architecture_mapping': artifact.get('architecture', {}).get('mapping_status'), + 'lineage': lineage.get('status', 'legacy'), 'notes': artifact['notes'], + 'coverage': {s['name']: s['coverage'] for s in lineage.get('stages', {}).values() if s['language'] in ('llvm', 'asm')}} + if show == 'architecture': + architecture = artifact.get('architecture', {}) + if node: return {**architecture, 'nodes': [n for n in architecture.get('nodes', []) if n['id'] == node['id'] or n.get('path', '').startswith(node.get('path', '') + '.')]} + return architecture + if show == 'stages': + return [{k: s[k] for k in ('id', 'name', 'language', 'track', 'line_count', 'op_count', 'source_path')} + for s in artifact['stages']] + if show == 'evidence': + return [e for e in artifact['evidence'] if selected is None or e['source_stage'] == selected['id']] + if not selected: raise ValueError(f'--show {show} requires --stage.') + if show == 'ir': + if node: raise ValueError('--module filters operations; use --show ops for module filtering.') + text = selected['text'].splitlines() + start, end = lines or (1, len(text)) + if start < 1 or end < start or end > len(text): raise ValueError(f'Line range must be within 1:{len(text)}.') + return {'stage': selected['name'], 'language': selected['language'], 'start_line': start, + 'end_line': end, 'text': '\n'.join(text[start-1:end])} + ops = operations(selected, lineage) + if source_lines is not None: + if selected['name'] == 'torch-input': ops = [op for op in ops if op['line'] in source_lines] + else: ops = [op for op in ops if any(o['line'] in source_lines for o in op.get('origins', []))] + if lines: ops = [op for op in ops if lines[0] <= op['line'] <= lines[1]] + return ops + + +def trace_report(artifact, lineage, *, op=None, module=None, source=None, from_stage=None, line=None, to='all'): + if sum(v is not None for v in (op, module, source, from_stage)) != 1: + raise ValueError('Choose one --op, --module, --source or --from-stage selector.') + if (from_stage is None) != (line is None): raise ValueError('--from-stage and --line must be supplied together.') + if not lineage: raise ValueError('This legacy artifact has no lineage sidecar; import its dump directory first.') + result = {'relationship': 'compiler-recorded source attribution; not persistent instruction identity', + 'lineage': lineage['status'], 'origins': [], 'matches': [], 'notes': list(lineage.get('notes', []))} + if from_stage: + stage = stage_for(artifact, from_stage) + if line < 1 or line > stage['line_count']: raise ValueError('Line is outside this stage.') + found = [r for r in operations(stage, lineage) if r['line'] == line] + result['matches'] = [{'stage': stage['name'], 'stage_id': stage['id'], **r} for r in found] + result['origins'] = list({(o['file'], o['line'], o['column']): o for r in found for o in r.get('origins', [])}.values()) + if not result['origins']: result['notes'].append('No resolved source attribution at this display line.') + return result + anchor = stage_for(artifact, 'torch-input') + columns = None + if source: + try: stage_id, line_str, col_str = source.rsplit(':', 2); source_line, column = int(line_str), int(col_str) + except ValueError: raise ValueError('Expected --source STAGE:LINE:COLUMN') from None + if stage_for(artifact, stage_id)['id'] != anchor['id']: raise ValueError('--source currently requires the torch-input anchor.') + if source_line < 1 or source_line > anchor['line_count'] or column < 1: raise ValueError('Source location is outside the anchor.') + selected_lines, columns = {source_line}, {column} + elif module: + node = module_for(artifact, module) + selected_lines = set(node.get('source_lines', [])) + result['module_mapping'] = node.get('mapping', 'unavailable') + else: + stage = stage_for(artifact, op.split(':', 1)[0]) + found = [r for r in operations(stage, lineage) if r['id'] == op] + if not found: raise ValueError(f'Unknown operation {op!r}; inspect --show ops lists IDs.') + if stage['id'] == anchor['id']: + selected_lines = {found[0]['line']} + else: + result['origins'] = found[0].get('origins', []) + selected_lines = {o['line'] for o in result['origins']} + # Preserve full origin tuples for a non-anchor operation. + exact = {(o['file'], o['line'], o['column']) for o in result['origins']} + for stage in artifact['stages']: + if stage['language'] not in (('llvm', 'asm') if to == 'all' else (to,)): continue + for r in operations(stage, lineage): + origins = [o for o in r.get('origins', []) if o['line'] in selected_lines and (columns is None or o['column'] in columns)] + if op and 'exact' in locals(): origins = [o for o in origins if (o['file'], o['line'], o['column']) in exact] + if origins: result['matches'].append({'stage': stage['name'], 'stage_id': stage['id'], **r}) + result['origins'] = list({(o['file'], o['line'], o['column']): o for r in result['matches'] for o in r.get('origins', []) if o['line'] in selected_lines and (columns is None or o['column'] in columns)}.values()) + if not result['matches']: result['notes'].append('No captured destination instructions resolve to this selector. Missing lineage is not inferred from operands.') + return result + + +def diff_report(artifact, lineage, before, after, mode='text'): + a, b = stage_for(artifact, before), stage_for(artifact, after) + if a['track'] != b['track']: raise ValueError(f"Incompatible comparison tracks: {a['track']} and {b['track']}.") + if mode == 'text': return ''.join(difflib.unified_diff(a['text'].splitlines(True), b['text'].splitlines(True), fromfile=a['name'], tofile=b['name'])) + ca, cb = (Counter(r.get('opcode', r.get('name')) for r in operations(s, lineage)) for s in (a, b)) + return {'from': a['name'], 'to': b['name'], 'track': a['track'], 'op_delta': b['op_count'] - a['op_count'], + 'operations': {k: cb[k]-ca[k] for k in sorted(ca.keys() | cb.keys()) if ca[k] != cb[k]}, + 'note': 'Operation-count comparison; not a proof of equivalence or a transformation attribution.'} diff --git a/compilerlens/server.py b/compilerlens/server.py new file mode 100644 index 0000000..9fa8609 --- /dev/null +++ b/compilerlens/server.py @@ -0,0 +1,58 @@ +"""Serve the existing browser application and saved artifacts from any install.""" +from pathlib import Path +import json +import threading +import webbrowser + +from .storage import load_run, workspace + + +def create_app(run, *, web_root=None): + from fastapi import FastAPI, HTTPException + from fastapi.responses import FileResponse + from fastapi.staticfiles import StaticFiles + from .backend.api.app import app as api + root, artifact, _ = load_run(run) + artifact_file = Path(run).expanduser().resolve() if Path(run).is_file() else root / 'artifact.json' + web = Path(web_root) if web_root else Path(__file__).parent / '_web' + if not (web / 'index.html').is_file(): + raise RuntimeError('Bundled webpage missing. Run python scripts/build_release.py or install a release wheel.') + app = FastAPI(title='CompilerLens') + # Share API behavior without adding static routes to its global app. + app.router.routes.extend(api.router.routes) + artifacts = workspace() / 'frontend/public/artifacts' + + @app.get('/artifacts/index.json') + def index(): + selected = json.loads((root / 'index.json').read_text()) if (root / 'index.json').exists() else { + 'workloads': [{'id': artifact['compilation_id'], 'title': artifact['compilation_id'], + 'description': '', 'source_entry': '', 'source_preview': '', + 'stage_count': len(artifact['stages']), 'op_count': 0, 'evidence_count': len(artifact['evidence'])}]} + if (artifacts / 'index.json').exists(): + others = json.loads((artifacts / 'index.json').read_text()).get('workloads', []) + ids = {w['id'] for w in selected['workloads']} + selected['workloads'].extend(w for w in others if w['id'] not in ids) + return selected + + @app.get('/artifacts/{name}.json') + def saved(name: str): + if name == artifact['compilation_id']: return FileResponse(artifact_file) + path = (artifacts / (name + '.json')).resolve() + if not path.is_relative_to(artifacts.resolve()) or not path.is_file(): raise HTTPException(404, 'Artifact not found') + return FileResponse(path) + + app.mount('/', StaticFiles(directory=web, html=True), name='web') + return app + + +def serve(run, *, port=8000, open_browser=False): + import uvicorn + if not 1 <= port <= 65535: raise ValueError('Port must be between 1 and 65535.') + app = create_app(run) + url = f'http://127.0.0.1:{port}' + print(f'CompilerLens: {url}', flush=True) + if open_browser: + timer = threading.Timer(1.0, webbrowser.open, args=(url,)) + timer.daemon = True + timer.start() + uvicorn.run(app, host='127.0.0.1', port=port) diff --git a/compilerlens/services.py b/compilerlens/services.py new file mode 100644 index 0000000..450545d --- /dev/null +++ b/compilerlens/services.py @@ -0,0 +1,190 @@ +"""Shared capture/import service used by the CLI, Python API and web backend.""" +from __future__ import annotations + +import dataclasses +import importlib +import importlib.util +import importlib.metadata +import json +import platform +import re +import shutil +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +from .storage import atomic_json, digest, slug + + +def save_inputs(inputs, root: Path) -> list[dict]: + import numpy as np + directory = root / 'inputs' + directory.mkdir(parents=True, exist_ok=True) + records = [] + for i, tensor in enumerate(inputs): + array = tensor.detach().cpu().contiguous().numpy() + path = directory / f'{i}.npy' + np.save(path, array, allow_pickle=False) + records.append({'path': str(path.relative_to(root)), 'shape': list(array.shape), + 'dtype': str(array.dtype), 'sha256': digest(path)}) + atomic_json(directory / 'index.json', records) + return records + + +def _fresh(out, name): + root = Path(out or Path.cwd() / 'compilerlens-runs' / f'{slug(name)}-{time.time_ns()}').expanduser().resolve() + root.mkdir(parents=True, exist_ok=False) + return root + + +def versions(): + result = {'python': platform.python_version(), 'platform': platform.platform()} + for package in ('compilerlens', 'torch', 'iree-base-compiler', 'iree-base-runtime', 'iree-turbine', 'transformers'): + try: result[package] = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: result[package] = 'not installed' + return result + + +def _finish(root, info, lineage, progress): + from .lineage import analyze_files + from .ingest.build import build_artifact, build_index + from .ingest.workloads.generated import spec_from_dump_dir + analysis = analyze_files(root, lineage, progress) + progress('Building artifact and source lineage') + spec = spec_from_dump_dir(root, info) + if not spec.stages: raise ValueError('No recognized compiler stages in the dump directory.') + artifact = build_artifact(spec, root=root, analysis=analysis) + atomic_json(root / 'artifact.json', dataclasses.asdict(artifact)) + atomic_json(root / 'index.json', build_index({spec.id: spec}, {spec.id: artifact})) + return analysis['status'] + + +def capture(model_id=None, *, example=None, python=None, out=None, seq_len=None, + revision=None, cpu='host', capture='standard', offline=False, seed=0, + lineage='auto', progress=None, max_parameters=None) -> Path: + """Compile one HF ID, built-in example, or local ``file.py:factory``. + + A factory returns ``(torch.nn.Module, tuple[Tensor, ...])``. Existing output + directories are refused. A failed run retains its status and diagnostic files. + """ + if sum(bool(v) for v in (model_id, example, python)) != 1: + raise ValueError('Choose exactly one MODEL_ID, --example, or --python.') + if not model_id and (seq_len is not None or revision is not None or offline): + raise ValueError('--seq-len, --revision and --offline apply only to Hugging Face models.') + if seq_len is not None and seq_len <= 0: raise ValueError('--seq-len must be positive.') + if capture not in ('standard', 'full') or lineage not in ('auto', 'required', 'off'): + raise ValueError('Invalid capture or lineage mode.') + if model_id: + from .models.detect import normalize_model_id + model_id = normalize_model_id(model_id) + if lineage == 'required': + from .lineage import native_binary + if not native_binary(): raise RuntimeError('Required native analyzer is unavailable; run compilerlens doctor.') + root = _fresh(out, model_id or example or Path(python.split(':')[0]).stem) + progress = progress or (lambda label: None) + started = time.monotonic() + state = {'schema_version': 1, 'status': 'running', 'created_at': datetime.now(timezone.utc).isoformat(), + 'parameters': dict(model_id=model_id, example=example, python=python, seq_len=(seq_len or 16) if model_id else None, + revision=revision, cpu=cpu, capture=capture, offline=offline, seed=seed, lineage=lineage), + 'versions': versions()} + def report(label): + state['phase'] = label + atomic_json(root / 'run.json', state) + progress(label) + try: + import torch + from .backend.compiler.runner import CompilerRunner, RunConfig, TRIM_ELIDE_ATTRS, TRIM_CODEGEN_PASSES, EXAMPLES + torch.manual_seed(seed) + report('Loading workload') + if model_id: + from .models.detect import detect + from .models.hf_wrapper import wrap, wrapper_source + detected = detect(model_id, revision=revision, seq_len=seq_len or 16, offline=offline) + module, inputs, info = wrap(detected, max_parameters=max_parameters) + source = wrapper_source(detected, seed=seed) + elif example: + if example not in EXAMPLES: raise ValueError(f'Unknown example: {example}. Choose {", ".join(EXAMPLES)}') + workload = importlib.import_module(EXAMPLES[example]) + module, inputs = workload.build_module(), workload.example_inputs() + info = {'model_id': example, 'kind': 'example'} + if hasattr(workload, 'model_info'): + info.update(workload.model_info()) + source = Path(workload.__file__).read_text() + else: + filename, separator, factory = python.rpartition(':') + if not separator or not factory.isidentifier(): raise ValueError('Expected --python file.py:factory') + filename = Path(filename).expanduser().resolve() + spec = importlib.util.spec_from_file_location('_compilerlens_workload_' + digest(filename)[:16], filename) + if not spec or not spec.loader: raise ValueError('Cannot load Python factory') + workload = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = workload + sys.path.insert(0, str(filename.parent)) + try: + spec.loader.exec_module(workload) + module, inputs = getattr(workload, factory)() + finally: + sys.path.remove(str(filename.parent)) + info = {'model_id': filename.stem, 'kind': 'python', 'factory': python, 'source_sha256': digest(filename)} + source = filename.read_text() + if not isinstance(module, torch.nn.Module) or not isinstance(inputs, (tuple, list)) or not inputs: + raise ValueError('Factory must return a torch module and nonempty tuple/list of example tensors.') + if any(not isinstance(t, torch.Tensor) for t in inputs): raise ValueError('Only tensor example inputs are supported.') + module.eval() + info['param_count'] = sum(p.numel() for p in module.parameters()) + if max_parameters is not None and info['param_count'] > max_parameters: + raise ValueError(f"{info['param_count']} parameters exceeds the {max_parameters} parameter interactive limit") + atomic_json(root / 'model_info.json', info) + (root / 'source.py').write_text(source) + state['model_info'] = info + state['inputs'] = save_inputs(inputs, root) + standard = capture == 'standard' + runner = CompilerRunner(RunConfig(layout='ingest', target_cpu=cpu, debug_symbols=True, + elide_attrs_larger_than=TRIM_ELIDE_ATTRS if standard else None, + pass_log_after=TRIM_CODEGEN_PASSES if standard else ())) + result = runner.run(module, tuple(inputs), name=root.name, output_dir=root, model_info=info, + on_progress=lambda label, _done, _total: report(label)) + manifest = json.loads(result.manifest_path.read_text()) + if manifest.get('errors'): + failure = manifest['errors'][0] + raise RuntimeError(f"Compiler failed at {failure['stage']}: {failure.get('stderr', '')[-2000:]}") + state['lineage'] = _finish(root, info, lineage, report) + state['status'] = 'complete' + # Retain _full: it is the authoritative export for recompile/reproducibility. + state['file_hashes'] = {str(p.relative_to(root)): digest(p) for folder in ('mlir', 'llvm', '_full') + for p in (root / folder).glob('*') if p.is_file()} + except BaseException as exc: + state.update(status='failed', error=f'{type(exc).__name__}: {exc}') + raise + finally: + state['elapsed_seconds'] = round(time.monotonic() - started, 3) + atomic_json(root / 'run.json', state) + return root + + +def import_dump(source, *, out=None, lineage='auto', progress=None) -> Path: + source = Path(source).expanduser().resolve() + if not source.is_dir() or not ((source / 'mlir').is_dir() or (source / 'llvm').is_dir()): + raise ValueError('Expected a dump directory containing mlir/ or llvm/.') + if out and Path(out).expanduser().resolve().is_relative_to(source): + raise ValueError('Import output must be outside the source directory.') + root = _fresh(out, source.name) + state = {'schema_version': 1, 'status': 'running', 'imported_from': str(source), 'versions': versions()} + atomic_json(root / 'run.json', state) + try: + # Copy only capture inputs, never arbitrary output or recursive run directories. + for name in ('mlir', 'llvm', 'passes', '_full', 'inputs', 'source.py', 'architecture.json', 'model_info.json', 'manifest.json', 'verification.json'): + path = source / name + if path.is_dir(): shutil.copytree(path, root / name) + elif path.is_file(): shutil.copy2(path, root / name) + for path in source.glob('*.vmfb'): shutil.copy2(path, root / path.name) + info_path = root / 'model_info.json' + info = json.loads(info_path.read_text()) if info_path.exists() else {'model_id': source.name, 'kind': 'import'} + atomic_json(info_path, info) + state['lineage'] = _finish(root, info, lineage, progress or (lambda label: None)) + state['status'] = 'complete' + except BaseException as exc: + state.update(status='failed', error=f'{type(exc).__name__}: {exc}') + raise + finally: atomic_json(root / 'run.json', state) + return root diff --git a/compilerlens/storage.py b/compilerlens/storage.py new file mode 100644 index 0000000..7c3b547 --- /dev/null +++ b/compilerlens/storage.py @@ -0,0 +1,59 @@ +"""Durable run storage, independent of the installation directory.""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +from pathlib import Path + + +def atomic_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp = tempfile.mkstemp(prefix=f'.{path.name}-', dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, indent=2, allow_nan=False) + stream.write('\n') + os.replace(temp, path) + finally: + Path(temp).unlink(missing_ok=True) + + +def digest(path: Path) -> str: + h = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + h.update(chunk) + return h.hexdigest() + + +def slug(value: str) -> str: + return re.sub(r'[^a-zA-Z0-9_.-]', '_', value).strip('.') or 'workload' + + +def workspace() -> Path: + if os.environ.get('COMPILERLENS_WORKSPACE'): + return Path(os.environ['COMPILERLENS_WORKSPACE']).expanduser().resolve() + repo = Path(__file__).resolve().parent.parent + if (repo / 'frontend' / 'package.json').is_file(): + return repo + return Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'compilerlens' + + +def load_run(path: str | Path) -> tuple[Path, dict, dict]: + path = Path(path).expanduser().resolve() + artifact_path = path / 'artifact.json' if path.is_dir() else path + artifact = json.loads(artifact_path.read_text()) + if artifact.get('artifact_version') != '0.7' or not artifact.get('stages'): + raise ValueError('Expected a CompilerLens artifact version 0.7 with stages.') + root = artifact_path.parent + lineage_path = root / 'lineage.json' + if not lineage_path.exists(): + # Read saved runs produced by the TestPyPI 0.1.0 release. + lineage_path = root / 'provenance.json' + lineage = json.loads(lineage_path.read_text()) if lineage_path.exists() else {} + if lineage and lineage.get('schema_version') != 1: + raise ValueError('Unsupported lineage schema; use a compatible CompilerLens version.') + return root, artifact, lineage diff --git a/compilerlens/toolchain.py b/compilerlens/toolchain.py new file mode 100644 index 0000000..a212c52 --- /dev/null +++ b/compilerlens/toolchain.py @@ -0,0 +1,9 @@ +"""Resolve installed console tools even when Python was invoked by absolute path.""" +from pathlib import Path +import shutil +import sys + + +def find_tool(name): + sibling = Path(sys.executable).parent / name + return str(sibling) if sibling.is_file() else shutil.which(name) diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..4b5c030 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,272 @@ +# CompilerLens CLI and native lineage + +[Project overview](../README.md) · [LLVM component](../llvm/README.md) · +[Release checklist](RELEASING.md) + +CompilerLens provides a complete Python package, the existing viewer, +a standalone LLVM analyzer and a loadable LLVM New Pass Manager plugin. The packaged analyzer +runs on actual IREE output. Source attribution and LLVM def-use edges are separate relationships. + +## Install from PyPI + +For a fresh virtual environment, follow the [CLI quick start](../README.md#cli-quick-start). +Inside that environment, install the published 0.2.1 release: + +```bash +python -m pip install compilerlens==0.2.1 --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu +compilerlens doctor +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens inspect runs/matmul +compilerlens view runs/matmul +``` + +This guide targets the published version 0.2.1. +See the [release checklist](RELEASING.md) for validation results and local wheel builds. +The release target is `compilerlens-0.2.1-py3-none-manylinux_2_35_x86_64.whl`. +It bundles the existing React app, +Monaco workers, private native executable, LLVM license, zlib and zstd libraries/licenses. +End users do not need Node, CMake, `opt` or an LLVM SDK. Dependencies include the validated +PyTorch, Transformers, IREE compiler/runtime and Turbine versions in `pyproject.toml`. + +The release wheel targets Linux x86-64 and was built/tested on glibc 2.35 with Python 3.10. +The build first produces a `linux_x86_64` wheel; auditwheel repair produces the tested +`manylinux_2_35_x86_64` wheel. macOS, Windows, ARM and older glibc +are not release targets. An LLVM 22 analyzer successfully parses the tested IREE 3.11.0 +LLVM 23 development snapshots; arbitrary future IR compatibility is not promised. + +## Capture parameters + +| Argument | Default | Meaning | +|---|---|---| +| `MODEL_ID` / `--example NAME` / `--python FILE:FACTORY` | Choose one | HF model with a supported adapter, packaged example, or local Python factory | +| `--seq-len N` | 16, HF only | Static text length (including CLIP); not used by image-only ViT | +| `--revision REF` | Resolve main | Record a concrete HF commit; offline requires a cached ref or explicit cached SHA | +| `--offline` | false | Config and model loaders use cached resources only | +| `--cpu NAME` | host | IREE LLVM CPU target | +| `--out DIR` | Fresh timestamped directory | Refuse an existing directory | +| `--capture standard/full` | standard | Trim displayed constants and select pass snapshots, or capture full logs | +| `--seed N` | 0 | Seed generated weights/inputs; actual tensors are saved | +| `--lineage auto/required/off` | auto | Native analysis with explicit fallback, fail if unavailable/incompatible, or skip extra LLVM/assembly analysis | +| `--format text/json` | text | JSON stdout is reserved for the result; diagnostics use stderr | + +```bash +compilerlens compile sshleifer/tiny-gpt2 --seq-len 8 --out runs/gpt2 +compilerlens compile sshleifer/tiny-gpt2 --offline \ + --revision 5f91d94bd9cd7190a9f3216ff93cd1dd95f2c7be --out runs/gpt2-offline +compilerlens compile --example linear_relu --out runs/linear +compilerlens compile --python workload.py:build --out runs/local +compilerlens import examples/matmul --out runs/imported --lineage required +``` + +`build()` returns `(torch.nn.Module, tuple_of_example_tensors)`. The module is put in eval mode. +This explicitly executes local code. HF-only arguments are rejected for local/examples inputs. +The examples are `matmul`, `linear_relu`, and `mini_transformer`. +Version 0.2.1 additionally includes `tiny_vit` and `tiny_clip` demos: +random weights, synthetic tensors, and no model downloads. See the +[model compatibility section](../README.md#supported-model-architectures) for their exact scope +and source-webpage commands. ViT/CLIP captures on host/generic CPU write `verification.json` +with a PyTorch-vs-compiled numerical check; failure prevents a successful capture. This checks +one input sample, not prediction quality. The existing text-model workflow is unchanged. + +### Validated HF checkpoints + +| Checkpoint | Pinned revision | Captured task | +|---|---|---| +| `WinKawaks/vit-tiny-patch16-224` | `77d1485af66b34d4ed0fe95dbb0c60c7496f950b` | 224×224 image encoder, classification head/pooler excluded | +| `wkcn/TinyCLIP-ViT-8M-16-Text-3M-YFCC15M` | `a2a8c6eaa2549ad66eb7c31b85022bf58273a26c` | 224×224 image + 16 text tokens → embeddings/similarity | + +Both pass CLI, Web Explore and Playground capture checks with the pinned toolchain in +`pyproject.toml`. Full captures include exact module-to-Torch mappings and compiler-recorded +LLVM/assembly source links. These are pretrained weights with synthetic inputs, not a +classification/retrieval quality evaluation or a guarantee for every checkpoint in the family. + +Reproduce from the checkout (set `HF_HOME` to your preferred cache directory): + +```bash +python scripts/validate_hf_models.py vit-tiny --out build/check-vit --lineage required +python scripts/validate_hf_models.py tinyclip --mode web --out build/check-clip --lineage required +# --mode playground checks the separate Playground API; --offline reuses cached weights. +``` + +Use fresh output directories. The validator reads [pinned profiles](../scripts/hf_checkpoints.json), +starts an isolated loopback API when needed, and saves `validation.json` with versions, +numerical results, coverage, elapsed time, peak child-process RSS and disk usage. For a wheel +environment, add `--installed --python /path/to/venv/bin/python`; it rejects source-package imports. +The optional **Pretrained model compatibility** GitHub workflow runs these real-download checks; +normal PR tests remain offline. A new manual workflow must reach the default branch before +GitHub exposes its Run workflow button. + +Float32 verification uses `rtol=1e-3, atol=1e-4`, rejects non-finite/mismatched outputs, and +records per-output errors. The relative tolerance was checked against float64 on three ViT +samples to account for differences in floating-point reductions; the absolute floor stays strict. + +The web limit remains **100M parameters**. Local full web captures used approximately 1.2 GiB +(ViT) and 3.1 GiB (TinyCLIP) peak process RSS, with about 0.5 GiB and 1.6 GiB of output respectively. +These are compilation measurements, not inference benchmarks or total-memory guarantees. +Standard `openai/clip-vit-base-patch32` has 151.3M parameters: a 200M cap would admit it, but its +compilation/resource behavior has not been validated here. The CLI has no web parameter cap. + +A failed capture keeps `run.json` with its error and phase. Successful runs include: + +```text +run.json resolved parameters, versions, tensor/file hashes, status +manifest.json compiler commands and diagnostics +inputs/*.npy actual tensor values (including decoder masks) +model_info.json +architecture.json torch.export module ownership when available +source.py +mlir/, llvm/, passes/ captured stages, every dispatch, LLVM checkpoints, assembly/objects +_full/ authoritative exports/resources retained for reproducibility +native/ per-snapshot JSON and annotated LLVM display views +artifact.json unchanged viewer schema 0.7 +lineage.json schema 1; origin sets, inline frames, def-use and coverage +index.json viewer workload entry +``` + +Standard capture still retains the authoritative export and can be large for large models. +Full capture can produce very large pass logs. Runs are self-contained and can be moved. +Legacy imports without manifests use unique captured-file aliases and report gaps where +metadata is absent; they cannot recover information the compiler never emitted. + +Current source uses `lineage` for the analysis module, pass, CLI output fields and sidecar. +The reader also accepts the older `provenance.json` sidecar from TestPyPI 0.1.0 runs. +Version 0.1.1 uses the new names. Version 0.1.0 retains its original names. + +## Inspect, trace, compare and benchmark + +```bash +compilerlens inspect runs/gpt2 --show architecture +compilerlens inspect runs/gpt2 --show stages +compilerlens inspect runs/gpt2 --show evidence +compilerlens inspect runs/gpt2 --show ops --stage torch-input +compilerlens inspect runs/gpt2 --show ops --stage torch-input --module transformer.h.0.mlp.c_fc +compilerlens inspect runs/gpt2 --show ir --stage llvm-optimized --lines 100:140 +compilerlens trace runs/gpt2 --module transformer.h.0.mlp.c_fc --to asm +compilerlens trace runs/gpt2 --op s00:op3 --to llvm # use an ID actually listed by inspect +compilerlens trace runs/imported --source torch-input:3:10 --to asm +compilerlens trace runs/imported --from-stage target-asm --line 106 +compilerlens diff runs/gpt2 --from llvm-codegen --to llvm-optimized --mode semantic +compilerlens bench runs/gpt2 --workers 8 --repetitions 10 +``` + +Stage IDs, module paths and operation IDs are discovered from the saved run. Native IDs +are qualified by stage in CLI output. Their identity is the snapshot's SHA-256 plus the +function/block/instruction ordinal, **not** a persistent identity across optimization. +Use `--format json` for complete structured records. Text output uses Rich tables/trees and +IR highlighting; piped output works without terminal color or an interactive pager. + +Forward queries keep every explicit origin attached to matching records. Reverse queries +show unknown lineage explicitly. `.file`/`.loc` state resets on line zero, section changes +and function boundaries. Inline callsites are distinguished from primary debug locations. +A debug location is a source association; it does not prove one-to-one instruction ancestry. + +Coverage separately counts debug locations, dispatch-resolved locations, Torch anchors and +exact model-module associations. Native reports retain per-function counts so runtime helpers +can be distinguished from dispatches. Count changes across snapshots do not identify a +specific pass as causing lineage loss. Semantic diffs compare operation counts, not equivalence. +Diffs reject unrelated tracks/modules. + +Benchmarking passes actual saved NPY arrays to IREE and verifies their hashes. It reports +median, variance, worker count, host noise notes and reliability. Imported captures without +saved inputs cannot be benchmarked through this command. + +### Object addresses + +```bash +compilerlens inspect runs/matmul --show objects +compilerlens trace runs/matmul --object 'llvm/FILE.o' \ + --section '.text.FUNCTION' --address 0x100 --format json +``` + +Use the object path and nonempty executable section name from `--show objects`. IREE often +uses function sections and an empty `.text`. The address is a **section-relative byte offset**, +not an assembly display line, process address or file offset. Native LLVM DWARF lookup +returns recorded frames and joins them to the captured dispatch and Torch source where possible. + +## Build/develop the native pass + +For system prerequisites and the complete source installation, start with the +[README source steps](../README.md#installation-from-source). Miniconda installs the build tools, +LLVM SDK and Python dependencies in one user-local environment without sudo, then you build +our pass and generate viewer artifacts. The source environment replaces the separate venv. +The PyPI package provides the separate prebuilt installation path. + +See the [LLVM component overview](../llvm/README.md) for the analysis flow and file layout. +Configure a fresh `build/llvm/` directory after the source-directory rename; CMake caches +contain absolute source paths and should not be moved from the previous build directory. + +```bash +cmake -S llvm -B build/llvm -G Ninja \ + -DLLVM_DIR=/path/to/llvm/lib/cmake/llvm -DCMAKE_BUILD_TYPE=Release +cmake --build build/llvm --parallel 2 +ctest --test-dir build/llvm --output-on-failure +/path/to/llvm/bin/opt -load-pass-plugin=build/llvm/CompilerLensPasses.so \ + -passes=compilerlens-lineage -disable-output run/llvm/FILE.optimized.ll +build/llvm/compilerlens-native --input run/llvm/FILE.optimized.ll \ + --output report.json --annotated-ir view.ll +``` + +The pass walks LLVM's real `Module`, `Instruction`, `DebugLoc` and `DILocation` APIs, recording +inline chains, scopes, discriminators, memory effects, vector types and operand instruction IDs. +It returns `PreservedAnalyses::all()`. CTest checks the standalone/plugin reports agree, +annotations parse, invalid IR fails, object lookups work and normalized IR is unchanged. + +Use an LLVM 22 SDK matching `opt` for the tested plugin. The CMake project permits LLVM 23 +for source-build development, which requires validation with that particular SDK. The plugin +ABI must match its host. `COMPILERLENS_NATIVE=/path/to/compilerlens-native` overrides the +private executable; `COMPILERLENS_WORKSPACE=/path` selects server storage. The README's Conda +build uses `build/llvm-conda/`; set `COMPILERLENS_NATIVE` to that build's executable as shown +there. These generic commands also work with a separately provisioned compatible SDK. + +After building the analyzer, regenerate the source frontend's existing workloads with: + +```bash +cd frontend +npm run artifact -- --lineage required +``` + +The artifact builder invokes the same native analysis as CLI captures and writes `native/` +reports and `lineage.json` beside each workload's dumps. It preserves the original LLVM IR. +The default mode is `auto` (native analysis with fallback); `--lineage off` skips native +analysis. The command prints each workload's lineage status. Source Web Explore uses +`COMPILERLENS_NATIVE` from the API terminal; the README selects the Conda build explicitly. +Both source and installed Web Explore use `auto` automatically, falling back if native +analysis is unavailable or fails. No lineage selection is needed in the webpage. + +An optional [IREE source-build hook](../llvm/iree/README.md) brackets its actual optimization +pipeline, including O0. Its C++ hook is tested; the pinned source patch is supplied. A full +IREE source build has not been performed. The ordinary wheel uses emitted snapshots. +There is no assumed `iree-compile --load-pass-plugin` support. + +## Build the wheel and verify + +Install frontend dependencies with `npm ci` in `frontend`, and use Python with setuptools >=77. +After configuring the native build: + +```bash +python scripts/build_release.py +python -m unittest discover -s tests -v +python -m pip install dist/compilerlens-0.2.1-py3-none-linux_x86_64.whl +python scripts/check_installed.py /path/to/installed/env/bin/python /path/to/run --browser +``` + +`build_release.py` builds/tests C++, installs private assets, bundles the unchanged application, +runs TypeScript checking and builds the wheel. It accepts `--cmake /path/to/cmake` and +`--llvm-dir /path/to/llvm/lib/cmake/llvm`. Source wheel builds require prepared assets; +`--assets-only` prepares them without building the wheel. Browser verification requires a +Playwright Chromium installation. Browser components and styling are unchanged. + +On this workspace's LLVM SDK, configure using: + +```bash +/pkg/qct/software/cmake/3.31.5/bin/cmake -S llvm -B build/llvm -G Ninja \ + -DLLVM_DIR=/pkg/qct/software/llvm/22.1.8/lib/cmake/llvm \ + -DCMAKE_C_COMPILER=/pkg/qct/software/llvm/22.1.8/bin/clang \ + -DCMAKE_CXX_COMPILER=/pkg/qct/software/llvm/22.1.8/bin/clang++ \ + -DCMAKE_CXX_FLAGS=--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/11 \ + -DCMAKE_MAKE_PROGRAM=/pkg/qct/software/ninja/1.12.1/ninja \ + -DCOMPILERLENS_ZSTD_LIBRARY=/usr/lib/x86_64-linux-gnu/libzstd.so.1 \ + -DCMAKE_BUILD_TYPE=Release +python scripts/build_release.py --cmake /pkg/qct/software/cmake/3.31.5/bin/cmake +``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..d3a83ea --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,199 @@ +# Releasing CompilerLens + +[Project overview](../README.md) · [CLI guide](CLI.md) · [0.2.1 notes](releases/0.2.1.md) + +## Release status + +**0.2.1 is published on [PyPI](https://pypi.org/project/compilerlens/0.2.1/), and its +[GitHub Release](https://github.com/Soumyaworks/CompilerLens/releases/tag/v0.2.1) is complete**, +as confirmed by the maintainer. Historical checks and the 0.1.1 checksum are preserved in +[the 0.1.1 notes](releases/0.1.1.md). +The `v0.2.0` tag remains unchanged: its GitHub build failed at wheel repair before publication. +Version 0.2.1 pins the tested repair tools and verifies full wheel builds before tagging. + +Target: Linux x86-64, glibc 2.35+, Python 3.10. The repaired wheel bundles the browser, +native analyzer and private libraries. macOS, Windows and ARM are not release targets. +The LLVM 22 analyzer is tested on the pinned IREE output; arbitrary LLVM versions and +the optional full IREE source-build integration are not validated. + +### Local verification — 30 September 2026 (0.2.1) + +Passed on Python 3.10.12 / Linux x86-64 / glibc 2.35: + +- 48 Python tests, including regression checks for old/shadowed `patchelf` and tag-only + publication; native CTest, TypeScript checking and bundled frontend build. +- Fresh packaging-tool environment with the workflow's requirements: `auditwheel 6.8.2` + and pip package `patchelf 0.19.1.0` (binary reports 0.19.1). The workflow's actual preflight, + wheel repair, metadata validation and manylinux platform check all passed. +- A separate fresh runtime venv, dependency resolution from PyPI/PyTorch CPU indexes, + `pip check`, and installed `doctor`. Python and packaged native versions both report 0.2.1. +- Required-native matmul (33 stages), `tiny_vit` (53) and `tiny_clip` (79) captures; + both vision demos passed numerical verification. +- Pinned ViT-Tiny CLI (52 stages) and TinyCLIP Web Explore (78), with exact module mappings, + numerical checks and native LLVM/assembly links. Model weights were reused from the HF cache. +- Installed matmul trace (208 assembly matches), HTTP/browser checks, and both pretrained + model viewers including desktop/mobile architecture, compiler workspace and Monaco. +- All three workflows pass `actionlint` 1.7.7. Component-version checks accept a branch build + and `v0.2.1`, and reject `v0.2.0` or a prerelease-form tag. The wheel's Python sources, + README metadata and native executable match the current checkout. + +Local wheel: `compilerlens-0.2.1-py3-none-manylinux_2_35_x86_64.whl` (about 18 MB). +SHA-256: `a036cee1f1a8766ba240672082c09449c7bce554f4254ee9cd4b6352043f5585`. +Build/runtime environments, installation report, captures and screenshots are retained +outside the checkout in `../compilerlens-release-0.2.1.nW8wS8/` (not for git). + +**Publication versus verification:** publication is confirmed, but an independent fresh-download +verification of the production 0.2.1 wheel is not recorded here. The checksum above belongs +to the local wheel, not necessarily GitHub's independently rebuilt wheel. + +## Prepare a future release + +The procedure below is reusable; **do not recreate `v0.2.1` or upload 0.2.1 again**. +Merging an already released branch into `main` does not require a version bump or a tag. +Branch/PR builds test the wheel; only a version-tag push can publish it. + +For a new release, choose an unused version and update it together in `pyproject.toml`, +`compilerlens/__init__.py`, `llvm/CMakeLists.txt`, `llvm/tools/main.cpp`, +`llvm/lib/Plugin.cpp` and `llvm/lib/Lineage.cpp`. The workflow checks they agree. +Add `docs/releases/.md`, update installation examples and keep publication status +as pending until confirmed. Only bump artifact/sidecar schemas when their format changes. +Rebuild and verify after changes to runtime code, dependencies or packaged metadata. + +## Build and verify locally + +Activate the source/build environment described in the README. The release script uses +`build/llvm`; configure with a matching LLVM SDK or reuse its existing configuration. +Do not reuse an incompatible CMake cache. Move old Python package staging directories +out of `build/` before building, so stale modules cannot enter the wheel. + +```bash +python -m pip install --upgrade pip "setuptools>=77" wheel twine tomli \ + "auditwheel==6.8.2" "patchelf==0.19.1.0" +export CLENS_VERSION="$(python -c 'import pathlib, tomli; print(tomli.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" +patchelf --version +python -m unittest discover -s tests -v +python scripts/build_release.py --llvm-dir "$CONDA_PREFIX/lib/cmake/llvm" +auditwheel repair "dist/compilerlens-${CLENS_VERSION}-py3-none-linux_x86_64.whl" --wheel-dir wheelhouse +python -m twine check "wheelhouse/compilerlens-${CLENS_VERSION}-py3-none-manylinux_2_35_x86_64.whl" +auditwheel show "wheelhouse/compilerlens-${CLENS_VERSION}-py3-none-manylinux_2_35_x86_64.whl" +``` + +Use the pip-provided `patchelf`, not Ubuntu 22.04's older apt package. The workflow +checks its installed version and executable path before building. Pass +`--cmake /path/to/cmake` for a configured toolchain that requires it. Use auditwheel's +actual platform tag if different, and investigate stricter requirements before release: +Conda LLVM builds can depend on different runtime libraries from GitHub's system LLVM build. + +Install the repaired wheel with dependencies in a **fresh venv outside the checkout**, +using PyPI and the PyTorch CPU index; do not use `--no-deps` or system site packages. +Clear `PYTHONPATH` and `COMPILERLENS_NATIVE`. Check `pip check`, `doctor`, +required-native matmul/`tiny_vit`/`tiny_clip` captures, assembly tracing and the viewer. + +Validate pretrained models with the installed Python; set `HF_HOME` to a disk with space. +Cached model weights are fine, but Python dependencies must belong to the clean venv: + +```bash +python scripts/validate_hf_models.py vit-tiny --installed \ + --python /absolute/path/to/venv/bin/python --out build/release-vit --lineage required +python scripts/validate_hf_models.py tinyclip --installed --mode web \ + --python /absolute/path/to/venv/bin/python --out build/release-clip --lineage required +``` + +Use fresh output paths. Test both installed model viewers, including architecture, +compiler workspace and Monaco. Source-browser success alone does not validate a wheel. + +## Before pushing the tag: GitHub and PyPI + +1. GitHub **Settings → Environments → pypi**: create/review the environment. If deployment + restrictions are enabled, allow release **tags**, such as `v*`; branch-only rules can + block publication. Add required reviewers if desired and supported by your plan. +2. In the existing PyPI `compilerlens` project's Publishing settings, review/add its + GitHub Trusted Publisher with **owner `Soumyaworks`**, **repository `CompilerLens`**, + **workflow `publish.yml`**, and **environment `pypi`**. No API-token secret is required. +3. Confirm your selected version is not already published and its `vX.Y.Z` tag does not + already exist locally or remotely. Check PyPI, `git tag --list` and `git ls-remote --tags origin`. + Do not move published tags or reuse uploaded versions. +4. Review and commit the explicit release files, push the branch, and wait for both ordinary + CI and **Publish to PyPI → Build and verify Linux wheel** to pass. On branches/PRs, + that workflow builds/tests only; its publish job is skipped. + Both `main` and `feature/experiments-cli` currently receive push builds; any other branch + should use a PR to get the full checks. Prefer releasing the tested `main` commit after + merge. If releasing before merge, tag the exact tested feature-branch commit instead. + +```bash +git diff --check +# Stage the explicit release files and review git diff --cached first. +git commit -m "release: prepare CompilerLens ${CLENS_VERSION}" +git push +``` + +Use your normal PR process; these commands do not bypass protection on `main`. For a new +branch without an upstream, first use `git push -u origin YOUR_BRANCH_NAME` with its actual +name. After any merge, wait for ordinary CI and the full wheel build on the resulting commit. +Check out the exact tested release commit with a clean working tree before tagging. +If using a new terminal, reactivate the build environment and read the version again: + +```bash +export CLENS_VERSION="$(python -c 'import pathlib, tomli; print(tomli.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" +git log -1 --oneline +# Only after checking this version/tag is unused and this exact commit passed both workflows: +git tag -a "v${CLENS_VERSION}" -m "CompilerLens ${CLENS_VERSION}" +git push origin "v${CLENS_VERSION}" +``` + +**Pushing the tag starts production publication, not a dry run.** It pauses for approval +only when required environment reviewers are configured. Do not also upload manually. + +## On GitHub after pushing the tag + +1. Open **Actions → Publish to PyPI** for your new `vX.Y.Z` tag. It checks all component versions, + runs Python/native/frontend tests, repairs the wheel and tests a fresh dependency + installation, packaged demos, native lineage and HTTP routes. +2. Approve the `pypi` deployment if configured. The workflow uploads the **same wheel + produced and tested by that GitHub run**, not the separately built local wheel. +3. Confirm [PyPI](https://pypi.org/project/compilerlens/) shows the new version, then perform the + fresh-download checks below. Local checks do not prove successful publication. +4. Open **Releases → Draft a new release**, choose the **existing new version tag**, + title it **CompilerLens X.Y.Z**, and use that version's release notes. + Replace the draft-status sentence only after publication is confirmed. + If attaching a wheel, use the workflow's `pypi-wheel` artifact, not the local build. +5. Record production verification and the downloaded wheel's checksum in that version's + release notes, separately from local-wheel results. The default-branch README updates + only when merged; PyPI's description is embedded in the uploaded wheel. + +The separate **Pretrained model compatibility** workflow is manual and must exist on the +default branch before GitHub exposes **Run workflow**. Its tests can be run locally +while the merge is deferred; it does not publish anything. + +## Verify the actual PyPI download + +Use a fresh directory outside the checkout after publication. Enter the exact published +version when prompted (for example, `0.2.1` to verify the current release): + +```bash +python3.10 -m venv .venv +source .venv/bin/activate +unset PYTHONPATH COMPILERLENS_NATIVE +python -m pip install --upgrade pip +read -r -p "Published version to verify (X.Y.Z): " CLENS_VERSION +python -m pip install --no-cache-dir "compilerlens==${CLENS_VERSION:?Enter the published version}" \ + --index-url https://pypi.org/simple/ --extra-index-url https://download.pytorch.org/whl/cpu +python -m pip check +compilerlens --version +compilerlens doctor +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens trace runs/matmul --module model --to asm +compilerlens compile --example tiny_clip --out runs/tiny-clip --lineage required +compilerlens view runs/tiny-clip +``` + +Repeat real-model/browser checks as appropriate. For a remote server, keep the viewer +running and follow the README's SSH tunnel instructions. Existing users can upgrade with +`python -m pip install --upgrade "compilerlens==${CLENS_VERSION:?Set the published version}"` +using the same indexes; existing +venvs do not update automatically. + +## Optional TestPyPI validation + +TestPyPI is optional: use separate TestPyPI credentials for a manual upload of a repaired +wheel if needed. The tag workflow targets **production PyPI**, not TestPyPI. diff --git a/docs/releases/0.1.1.md b/docs/releases/0.1.1.md new file mode 100644 index 0000000..dab40ee --- /dev/null +++ b/docs/releases/0.1.1.md @@ -0,0 +1,81 @@ +# CompilerLens 0.1.1 + +Version 0.1.1 packages the latest CLI, LLVM analysis, and documentation changes after +the initial 0.1.0 TestPyPI release. The supported wheel targets Linux x86-64 with +glibc 2.35 or newer; Python 3.10 is the validated interpreter. + +## Install + +In a Python 3.10 virtual environment on Linux x86-64: + +```bash +python -m pip install compilerlens==0.1.1 --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu +compilerlens doctor +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens view runs/matmul +``` + +Package: [compilerlens 0.1.1 on PyPI](https://pypi.org/project/compilerlens/0.1.1/). +Source: [`v0.1.1`](https://github.com/Soumyaworks/CompilerLens/tree/v0.1.1), +commit `cd818f28484430b5a884da65da2e1ca33788d76e` on `feature/experiments-cli`. + +## Changes + +- Rename the C++ source directory from `native/` to `llvm/` and build it in `build/llvm/`. +- Use lineage terminology in the Python implementation, C++ pass, CLI reports, and frontend. +- Preserve reading of saved runs created with version 0.1.0. +- Add repository, issue tracker, and documentation links to package metadata. +- Document fresh-environment installation, the primary CLI commands, and the combined + CLI/web compilation and analysis flow. Move planning notes out of the project repository. + +## Compatibility + +The primary CLI commands and `--lineage` options are unchanged. New runs write +`lineage.json`; the reader still accepts `provenance.json` from 0.1.0. If both files +exist, the new file takes precedence. The artifact schema remains 0.7 and the +analysis sidecar schema remains 1. + +Integrations using Python internals should import `compilerlens.lineage` instead of +`compilerlens.provenance`. CLI JSON reports and new `run.json` files use the field +`lineage` instead of `provenance`. The LLVM plugin pass is now named +`compilerlens-lineage`. The private executable remains `compilerlens-native`. + +The optional full IREE source-build integration remains unvalidated. The packaged +workflow runs the C++ analysis over IREE's emitted LLVM snapshots. + +See the [release checklist](https://github.com/Soumyaworks/CompilerLens/blob/v0.1.1/docs/RELEASING.md) +for validation and publication status and the +[CLI guide](https://github.com/Soumyaworks/CompilerLens/blob/v0.1.1/docs/CLI.md) +for usage at that release. See the [current release checklist](../RELEASING.md) for +subsequent releases. + +## Validation status — 28 September 2026 + +Local checks passed: 22 Python tests; native CTest; TypeScript and viewer build; repaired-wheel +metadata/platform checks; fresh dependency installation and `pip check`; installed CLI +`doctor`; matmul capture with required native analysis (33 stages); assembly tracing +(208 matches); exact-input benchmarking; and the installed HTTP/browser flow. Benchmark +timings were flagged as noisy. + +TestPyPI 0.1.1 was uploaded and freshly downloaded. Its SHA-256 matches the repaired local +wheel. Installation of the downloaded wheel into a separate clean environment, with fresh +dependency resolution from PyPI and the PyTorch CPU index, passed `pip check`, `doctor`, +matmul capture (33 stages), forward/reverse tracing (208 assembly matches), benchmarking, +and installed HTTP/browser verification. + +Production PyPI publication and fresh installation verification also passed. The installed +download matches the exact TestPyPI-tested wheel. In a new environment outside the checkout, +`pip check`, package/native version and import-path checks, `doctor`, required-native matmul +capture (33 stages), forward/reverse tracing (208 assembly matches), benchmarking, and +installed HTTP/browser checks all passed. The viewer check covered workload cards, the +workspace, Monaco and its worker. The production benchmark passed its reliability check; +this validates execution and is not a performance comparison. + +Wheel: `compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl` + +SHA-256: + +```text +053579416e9c84de30994329018aaf3fd94be905ff0d20770bb14d76998fcc7d +``` diff --git a/docs/releases/0.2.0.md b/docs/releases/0.2.0.md new file mode 100644 index 0000000..575f5ca --- /dev/null +++ b/docs/releases/0.2.0.md @@ -0,0 +1,62 @@ +# CompilerLens 0.2.0 + +Historical release attempt: `v0.2.0` was tagged, but GitHub's wheel repair failed because +Ubuntu 22.04 supplied `patchelf 0.14.3`. That run did not reach PyPI publication. +The tag is retained unchanged; use [0.2.1](0.2.1.md) for the corrected release instructions. + +## Highlights + +- Shared ViT image-encoder and CLIP image/text-similarity adapters across CLI, Web Explore, + Playground and source workflows, plus offline `tiny_vit` and `tiny_clip` demos. +- Modality-aware architecture views, observed tensor shapes and compiler-stage tracing. +- Pretrained ViT-Tiny and TinyCLIP validation with pinned revisions; ViT-Base also tested + through Web Explore. Hugging Face repository URLs, revision pins and offline loading + use the shared model-loading path. +- Float32 PyTorch-versus-compiled output verification for supported ViT/CLIP CPU captures. +- Native LLVM lineage in source workflows, a no-sudo Conda setup, and the Explore progress fix. +- Optional real-checkpoint compatibility workflow and stronger installed-wheel release checks. + +## Historical installation target (use 0.2.1 instead) + +On Linux x86-64 with glibc 2.35+ and Python 3.10: + +```bash +python3.10 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install compilerlens==0.2.0 --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu +python -m pip check +compilerlens doctor +compilerlens compile --example tiny_clip --out runs/tiny-clip --lineage required +compilerlens view runs/tiny-clip +``` + +The wheel bundles the browser app and native analyzer; users do not need Conda, Node, +CMake or an LLVM SDK. Use a fresh output directory for each capture. + +## Scope and compatibility + +- Existing CLI commands and saved-run compatibility are retained. Artifact schema remains + 0.7 and lineage sidecar schema remains 1; Python and native components report 0.2.0. +- ViT captures encoder states, not the classification head or pooler. CLIP captures image + and text embeddings plus similarity logits, not caption generation. +- These paths use static batch-one CPU/float32 synthetic inputs. Real-image preprocessing, + user captions, audio, encoder-decoder models and generation loops are not included. +- Web model loading retains the 100M-parameter limit. Compatibility is checkpoint-specific; + see the [validated profiles](../CLI.md#validated-hf-checkpoints). +- Source/assembly attribution depends on emitted compiler locations. Missing locations + remain unlinked; this is not a guarantee of complete cross-pass instruction identity. + +## Validation + +Local release-wheel checks passed on 30 September 2026: 44 Python tests, native CTest, +frontend build, wheel/platform checks, a fresh dependency installation, installed native +matmul and vision-demo captures, pretrained ViT-Tiny CLI and TinyCLIP Web Explore checks, +numerical verification, LLVM/assembly tracing and installed browser checks. + +Historical local wheel SHA-256: +`5386a5c2e93bfa010d528ad79f98b22ab5304e4f5864936885bbb11e3ad0b842`. +These results apply to the locally built 0.2.0 wheel, not a published PyPI artifact. +The tag-triggered GitHub run subsequently failed during repair; publication is superseded +by 0.2.1. See the [release checklist](../RELEASING.md) for the current procedure. diff --git a/docs/releases/0.2.1.md b/docs/releases/0.2.1.md new file mode 100644 index 0000000..798fe21 --- /dev/null +++ b/docs/releases/0.2.1.md @@ -0,0 +1,56 @@ +# CompilerLens 0.2.1 + +Published to [PyPI](https://pypi.org/project/compilerlens/0.2.1/), with the +[GitHub Release](https://github.com/Soumyaworks/CompilerLens/releases/tag/v0.2.1) +completed, as confirmed by the maintainer. This replaces the failed 0.2.0 publication +attempt; the existing `v0.2.0` tag is retained unchanged. + +## Changes + +- Fix Linux wheel packaging by pinning `auditwheel==6.8.2` and `patchelf==0.19.1.0`. + Ubuntu 22.04's `patchelf 0.14.3` is too old for the repair step and is no longer installed + by the workflow. A preflight checks package versions and the actual executable on PATH. +- Build and smoke-test release wheels on branch pushes and PRs, before tagging. + PyPI upload remains restricted to version-tag pushes and the `pypi` environment. +- Include the [0.2.0 feature set](0.2.0.md): ViT/CLIP adapters, offline vision demos, + modality-aware architecture views, numerical checks, native lineage in source workflows + and the no-sudo Conda setup. Runtime behavior is unchanged apart from version reporting. + +## Install or upgrade + +Activate a Python 3.10 venv on Linux x86-64 with glibc 2.35+: + +```bash +python -m pip install --upgrade compilerlens==0.2.1 \ + --index-url https://pypi.org/simple/ --extra-index-url https://download.pytorch.org/whl/cpu +python -m pip check +compilerlens --version +compilerlens doctor +compilerlens compile --example tiny_clip --out runs/tiny-clip --lineage required +compilerlens view runs/tiny-clip +``` + +Stop any running viewer before upgrading and restart it afterwards. Existing captures +are retained. Use a fresh output directory when compiling. End users do not need CMake, +LLVM, Node, `auditwheel` or `patchelf`; the wheel includes the app and native analyzer. + +## Scope + +ViT captures encoder states without the classifier/pooler. CLIP captures image/text +embeddings and similarity, not caption generation. These are static batch-one CPU/float32 +synthetic-input workloads, not real-media preprocessing or quality evaluation. The web +parameter cap remains 100M. Artifact schema 0.7 and lineage schema 1 are unchanged. + +## Validation and publication + +Local checks passed on 30 September 2026: 48 tests, native/frontend builds, wheel repair +with the pinned packaging tools, metadata/platform checks, a clean dependency installation, +required-native matmul/vision-demo captures, pretrained ViT-Tiny CLI and TinyCLIP Web Explore, +numerical verification, assembly tracing and installed desktop/mobile browser checks. + +See the [release checklist](../RELEASING.md#local-verification--30-september-2026-021) +for the retained local results. Publication is confirmed, but an independent fresh-download +verification of the production 0.2.1 wheel is not recorded here. The local wheel checksum +must not be treated as the checksum of GitHub's independently rebuilt artifact. +The [current release procedure](../RELEASING.md) applies to future versions; do not recreate +`v0.2.1` or upload version 0.2.1 again. diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..7cf4feb --- /dev/null +++ b/environment.yml @@ -0,0 +1,21 @@ +name: compilerlens-dev +variables: + # Keep unrelated packages from ~/.local out of this development environment. + PYTHONNOUSERSITE: "1" +channels: + - conda-forge + - nodefaults +dependencies: + - python=3.10 + - pip + - git + - nodejs=22 + - cmake=3.31 + - ninja=1.12 + - c-compiler + - cxx-compiler + - llvmdev=22.1.8 + - llvm-tools=22.1.8 + - zlib + - zstd + - libxml2-devel diff --git a/frontend/src/ArchitectureExplorerPage.tsx b/frontend/src/ArchitectureExplorerPage.tsx index 2a3fe47..5aec69d 100644 --- a/frontend/src/ArchitectureExplorerPage.tsx +++ b/frontend/src/ArchitectureExplorerPage.tsx @@ -215,6 +215,7 @@ function Explorer({ .map(edge => nodeMap.get(edge.target)) .filter(Boolean) as ArchitectureNode[]; const facts = architecture.model; + const multimodal = (facts.modalities?.length ?? 0) > 1; function chooseNode(id: string) { setSelectedId(id); @@ -239,6 +240,8 @@ function Explorer({ {facts.layer_count != null && Layers{facts.layer_count}} {facts.hidden_size != null && Hidden{facts.hidden_size}} {facts.attention_heads != null && Heads{facts.attention_heads}} + {facts.modalities && Modalities{facts.modalities.join(' + ')}} + {facts.weights === 'random' && Demo weightsRandom} {mappingLabel(architecture.mapping_status)} @@ -268,7 +271,7 @@ function Explorer({ /> -
+

{architecture.mapping_note}

@@ -287,18 +290,26 @@ function Explorer({ ))}
- {visibleRoutes.length > 0 ? `${visibleRoutes.length} observed module route${visibleRoutes.length === 1 ? '' : 's'} · ` : ''} + {multimodal ? 'Components, not execution order · ' : + visibleRoutes.length > 0 ? `${visibleRoutes.length} observed module route${visibleRoutes.length === 1 ? '' : 's'} · ` : ''} Select for details · double-click to step inside
-
InputsTokens and attention mask
-