.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
-
+
+ `${input.name}: ${input.shape.join(' × ')} ${input.dtype}`).join('\n')}` : undefined}>
+ {facts.input_profile?.kind === 'synthetic' ? 'Synthetic inputs' : 'Inputs'}
+ {facts.modalities?.includes('image') ?
+ (facts.modalities.includes('text') ? 'Image + text + mask' : 'Image tensor') :
+ facts.input_profile ? facts.input_profile.inputs.map(input => input.name).join(', ') : 'Model tensors'}
+
+ {!multimodal &&
}
7 ? ' is-dense' : '')}>
{canvasNodes.map((node, index) => (
- {index > 0 &&
›}
+ {!multimodal && index > 0 &&
›}
chooseNode(node.id)}
@@ -307,7 +318,10 @@ function Explorer({
))}
-
Output{facts.causal ? 'Token logits' : 'Hidden states'}
+
Output
+ {facts.task === 'image-text-similarity' ? 'Embeddings + similarity' :
+ facts.output_names?.join(', ') ?? (facts.causal ? 'Token logits' : 'Model tensors')}
+
diff --git a/frontend/src/LandingPage.tsx b/frontend/src/LandingPage.tsx
index 5efeb64..cab9539 100644
--- a/frontend/src/LandingPage.tsx
+++ b/frontend/src/LandingPage.tsx
@@ -97,7 +97,9 @@ function CompilerPipeline({searching, progress}: {searching: boolean; progress:
function workloadLabels(workload: WorkloadSummary): string[] {
const text = `${workload.title} ${workload.description}`.toLowerCase();
let family = 'PyTorch';
- if (text.includes('roberta')) family = 'RoBERTa';
+ if (text.includes('clip')) family = 'CLIP';
+ else if (text.includes('vit')) family = 'ViT';
+ else if (text.includes('roberta')) family = 'RoBERTa';
else if (text.includes('bert')) family = 'BERT';
else if (text.includes('pythia')) family = 'Pythia';
else if (text.includes('gpt')) family = 'GPT-2';
@@ -105,8 +107,10 @@ function workloadLabels(workload: WorkloadSummary): string[] {
else if (text.includes('linear')) family = 'Linear';
let shape = 'Module';
- if (text.includes('causal decoder')) shape = 'Decoder';
- else if (text.includes('bidirectional encoder')) shape = 'Encoder';
+ if (text.includes('image-text-similarity')) shape = 'Image + Text';
+ else if (text.includes('image-encoder')) shape = 'Image';
+ else if (text.includes('causal decoder') || text.includes('causal-lm-forward')) shape = 'Decoder';
+ else if (text.includes('bidirectional encoder') || text.includes('text-encoder')) shape = 'Encoder';
else if (text.includes('fusion')) shape = 'Fusion';
else if (text.includes('matmul')) shape = 'Kernel';
diff --git a/frontend/src/api/artifact.ts b/frontend/src/api/artifact.ts
index 8103883..6ca6025 100644
--- a/frontend/src/api/artifact.ts
+++ b/frontend/src/api/artifact.ts
@@ -181,6 +181,16 @@ export interface ArchitectureEdge {
}
export interface ArchitectureModelFacts {
+ modalities?: string[];
+ task?: string;
+ weights?: string;
+ input_profile?: {
+ kind: string;
+ batch_size: number;
+ preprocessing: string;
+ inputs: {name: string; shape: number[]; dtype: string}[];
+ };
+ output_names?: string[];
model_id?: string;
model_type?: string | null;
causal?: boolean;
diff --git a/frontend/src/api/sandbox.ts b/frontend/src/api/sandbox.ts
index 83710e3..a7dc599 100644
--- a/frontend/src/api/sandbox.ts
+++ b/frontend/src/api/sandbox.ts
@@ -75,6 +75,7 @@ export interface Job {
bench: Bench | null;
error: string | null;
artifact_id?: string | null;
+ lineage?: LineageMode | null;
/** Real progress through the `/explore` pipeline -- null for the Playground's `/compile`,
* which only ever does one stage and doesn't report this. */
progress?: {label: string; done: number; total: number} | null;
@@ -142,10 +143,12 @@ export function startCompile(body: {
}
/** Full pipeline compilation used by the landing-page model search. */
-export function startExplore(model_id: string, seq_len = 16) {
+export type LineageMode = 'auto' | 'required' | 'off';
+
+export function startExplore(model_id: string, seq_len = 16, lineage: LineageMode = 'auto') {
return call<{job_id: string; status: string}>('/explore', {
method: 'POST',
- body: JSON.stringify({model_id, seq_len}),
+ body: JSON.stringify({model_id, seq_len, lineage}),
});
}
diff --git a/frontend/src/styles/architecture.css b/frontend/src/styles/architecture.css
index 0fad33a..2364ef9 100644
--- a/frontend/src/styles/architecture.css
+++ b/frontend/src/styles/architecture.css
@@ -256,12 +256,13 @@
.architecture-tree-select small { margin-top: 2px; color: #596875; font: 7px var(--font-mono); }
.architecture-tree-select i { color: #61717e; font: normal 7px var(--font-mono); }
-.architecture-provenance { display: flex; gap: 9px; margin: 8px; padding: 11px 12px; border: 1px solid rgba(148, 184, 216, 0.08); border-radius: 10px; background: rgba(255, 255, 255, 0.025); }
-.architecture-provenance .architecture-map-dot { margin-top: 3px; }
-.architecture-provenance p { margin: 0; color: #74838f; font-size: 9px; line-height: 1.5; }
+.architecture-lineage { display: flex; gap: 9px; margin: 8px; padding: 11px 12px; border: 1px solid rgba(148, 184, 216, 0.08); border-radius: 10px; background: rgba(255, 255, 255, 0.025); }
+.architecture-lineage .architecture-map-dot { margin-top: 3px; }
+.architecture-lineage p { margin: 0; color: #74838f; font-size: 9px; line-height: 1.5; }
.architecture-canvas {
display: grid;
+ grid-template-columns: minmax(0, 1fr);
grid-template-rows: 43px minmax(260px, 1fr) auto;
min-width: 0;
min-height: 0;
@@ -432,7 +433,7 @@
.architecture-canvas { min-height: 680px; }
.architecture-chip-floor { grid-template-columns: 1fr; align-items: stretch; }
.architecture-flow-line { top: 35px; bottom: 35px; left: 50%; width: 1px; height: auto; }
- .architecture-card-flow { grid-template-columns: 1fr 1fr; }
+ .architecture-card-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.architecture-input-terminal, .architecture-output-terminal { min-height: 48px; text-align: left; }
.architecture-input-terminal i, .architecture-output-terminal i { display: none; }
.architecture-phase-track { overflow-x: auto; }
diff --git a/frontend/vite.release.config.ts b/frontend/vite.release.config.ts
new file mode 100644
index 0000000..4c3e64c
--- /dev/null
+++ b/frontend/vite.release.config.ts
@@ -0,0 +1,10 @@
+import react from '@vitejs/plugin-react';
+import {defineConfig} from 'vite';
+
+// Same application and workers, with local API routes and no development artifacts.
+export default defineConfig({
+ plugins: [react()],
+ publicDir: false,
+ define: {'import.meta.env.VITE_SANDBOX_API': JSON.stringify('')},
+ build: {outDir: '../compilerlens/_web', emptyOutDir: true},
+});
diff --git a/ingest/__init__.py b/ingest/__init__.py
index e69de29..5083abf 100644
--- a/ingest/__init__.py
+++ b/ingest/__init__.py
@@ -0,0 +1,3 @@
+"""Source-checkout compatibility; installed API is compilerlens.ingest."""
+from compilerlens import ingest as _package
+__path__ = _package.__path__
diff --git a/llvm/CMakeLists.txt b/llvm/CMakeLists.txt
new file mode 100644
index 0000000..a48d3e3
--- /dev/null
+++ b/llvm/CMakeLists.txt
@@ -0,0 +1,40 @@
+cmake_minimum_required(VERSION 3.20)
+project(CompilerLensLLVM VERSION 0.2.1 LANGUAGES C CXX)
+find_package(LLVM REQUIRED CONFIG)
+set(COMPILERLENS_ZSTD_LIBRARY "" CACHE FILEPATH "zstd library for LLVM SDKs missing its CMake package")
+if(COMPILERLENS_ZSTD_LIBRARY AND NOT TARGET zstd::libzstd_static)
+ add_library(zstd::libzstd_static UNKNOWN IMPORTED)
+ set_target_properties(zstd::libzstd_static PROPERTIES IMPORTED_LOCATION "${COMPILERLENS_ZSTD_LIBRARY}")
+endif()
+if(LLVM_VERSION_MAJOR LESS 22 OR LLVM_VERSION_MAJOR GREATER 23)
+ message(FATAL_ERROR "CompilerLens supports LLVM 22/23 development toolchains")
+endif()
+list(APPEND CMAKE_MODULE_PATH "${LLVM_CMAKE_DIR}")
+include(AddLLVM)
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+include_directories(SYSTEM ${LLVM_INCLUDE_DIRS})
+include_directories("${CMAKE_CURRENT_SOURCE_DIR}/include")
+add_definitions(${LLVM_DEFINITIONS})
+add_library(CompilerLensLineage STATIC lib/Lineage.cpp)
+llvm_update_compile_flags(CompilerLensLineage)
+add_llvm_pass_plugin(CompilerLensPasses lib/Plugin.cpp)
+target_link_libraries(CompilerLensPasses PRIVATE CompilerLensLineage)
+add_executable(compilerlens-native tools/main.cpp)
+llvm_update_compile_flags(compilerlens-native)
+llvm_map_components_to_libnames(CLENS_LIBS Core IRReader Analysis Passes Support Object DebugInfoDWARF)
+target_link_libraries(compilerlens-native PRIVATE CompilerLensLineage ${CLENS_LIBS})
+set_target_properties(compilerlens-native PROPERTIES INSTALL_RPATH "$ORIGIN/lib")
+install(TARGETS compilerlens-native RUNTIME DESTINATION compilerlens/_bin)
+install(FILES ${LLVM_MAIN_INCLUDE_DIR}/../LICENSE.TXT DESTINATION compilerlens/_bin OPTIONAL)
+include(CTest)
+if(BUILD_TESTING)
+ find_package(Python3 COMPONENTS Interpreter REQUIRED)
+ add_executable(compilerlens-checkpoint-test tests/checkpoint.cpp)
+ llvm_update_compile_flags(compilerlens-checkpoint-test)
+ target_link_libraries(compilerlens-checkpoint-test PRIVATE CompilerLensLineage ${CLENS_LIBS})
+ add_test(NAME lineage COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_reports.py
+ $ ${LLVM_TOOLS_BINARY_DIR}/opt $
+ ${CMAKE_CURRENT_SOURCE_DIR}/tests $ ${LLVM_TOOLS_BINARY_DIR}/llc)
+endif()
diff --git a/llvm/LLVM-LICENSE.txt b/llvm/LLVM-LICENSE.txt
new file mode 100644
index 0000000..5715176
--- /dev/null
+++ b/llvm/LLVM-LICENSE.txt
@@ -0,0 +1,278 @@
+==============================================================================
+The LLVM Project is under the Apache License v2.0 with LLVM Exceptions:
+==============================================================================
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+---- LLVM Exceptions to the Apache 2.0 License ----
+
+As an exception, if, as a result of your compiling your source code, portions
+of this Software are embedded into an Object form of such source code, you
+may redistribute such embedded portions in such Object form without complying
+with the conditions of Sections 4(a), 4(b) and 4(d) of the License.
+
+In addition, if you combine or link compiled forms of this Software with
+software that is licensed under the GPLv2 ("Combined Software") and if a
+court of competent jurisdiction determines that the patent provision (Section
+3), the indemnity provision (Section 9) or other Section of the License
+conflicts with the conditions of the GPLv2, you may retroactively and
+prospectively choose to deem waived or otherwise exclude such Section(s) of
+the License, but only in their entirety and only with respect to the Combined
+Software.
+
+==============================================================================
+Software from third parties included in the LLVM Project:
+==============================================================================
+The LLVM Project contains third party software which is under different license
+terms. All such code will be identified clearly using at least one of two
+mechanisms:
+1) It will be in a separate directory tree with its own `LICENSE.txt` or
+ `LICENSE` file at the top containing the specific license and restrictions
+ which apply to that software, or
+2) It will contain specific license and restriction terms at the top of every
+ file.
+
+==============================================================================
+Legacy LLVM License (https://llvm.org/docs/DeveloperPolicy.html#legacy):
+==============================================================================
+University of Illinois/NCSA
+Open Source License
+
+Copyright (c) 2003-2019 University of Illinois at Urbana-Champaign.
+All rights reserved.
+
+Developed by:
+
+ LLVM Team
+
+ University of Illinois at Urbana-Champaign
+
+ http://llvm.org
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal with
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimers.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimers in the
+ documentation and/or other materials provided with the distribution.
+
+ * Neither the names of the LLVM Team, University of Illinois at
+ Urbana-Champaign, nor the names of its contributors may be used to
+ endorse or promote products derived from this Software without specific
+ prior written permission.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE
+SOFTWARE.
diff --git a/llvm/README.md b/llvm/README.md
new file mode 100644
index 0000000..19a1027
--- /dev/null
+++ b/llvm/README.md
@@ -0,0 +1,64 @@
+# CompilerLens LLVM analysis
+
+This directory contains CompilerLens's C++ analysis code. It uses the LLVM SDK to
+read the LLVM IR and object files emitted by IREE and report the instructions and
+source locations they contain. IREE performs model compilation and optimization;
+this component inspects its output without changing the program.
+
+## How it fits into a capture
+
+1. The Python compiler runner exports the model and invokes IREE with debug information.
+2. IREE writes LLVM snapshots, assembly listings, dispatch sources and object files
+ into the saved run's `llvm/` directory.
+3. `compilerlens/lineage.py` invokes the private `compilerlens-native` executable
+ for each LLVM snapshot. The executable writes a JSON report and an LLVM display
+ copy annotated with instruction IDs.
+4. Python joins reported debug locations through captured dispatch MLIR to Torch
+ source locations and model-module mappings. It separately reads assembly
+ `.file`/`.loc` directives to associate assembly instructions with source locations.
+5. The results populate `lineage.json` and the existing viewer artifact, supporting
+ `inspect`, `trace`, and the browser's operation lineage views.
+
+The repository's `llvm/` directory is source code. A saved run's `llvm/` directory
+contains compiler output; its `native/` directory contains analyzer reports and
+annotated display copies. Renaming the source directory does not move saved runs.
+
+## Components
+
+| Path | Responsibility |
+|---|---|
+| `lib/Lineage.cpp`, `include/compilerlens/Lineage.h` | Walk LLVM modules, functions, blocks and instructions; record instruction types, operands, memory effects, debug locations, inline frames and coverage counts. |
+| `tools/main.cpp` | Standalone command-line driver: parse and verify IR, run the analysis, write reports/annotated IR, and query object sections and DWARF source frames. |
+| `lib/Plugin.cpp` | Register the same analysis as `compilerlens-lineage` for a matching LLVM `opt` executable. |
+| `include/compilerlens/Checkpoint.h`, `iree/` | Optional hook for reports before and after optimization inside a source-built IREE compiler. |
+| `tests/` | Verify reports, source frames, dependencies, valid annotations, object lookups, error handling and preservation of LLVM IR. |
+| `CMakeLists.txt` | Build the analysis library, executable, plugin and test driver; install the private executable. |
+
+The pass returns `PreservedAnalyses::all()`. It reads compiler metadata and does not
+instrument execution or optimize the model. Instruction IDs identify positions in
+one snapshot; they do not prove identity across transformations. Source locations
+can be missing or shared, and operand dependencies do not establish source ownership.
+
+## Build and integration
+
+The [README source installation](../README.md#installation-from-source) uses Miniconda to
+install the toolchain, LLVM SDK and Python dependencies without sudo. It builds this directory
+under `build/llvm-conda/` and selects the executable with `COMPILERLENS_NATIVE`.
+The PyPI package provides the separate prebuilt installation path.
+
+Use the [CLI guide's build instructions](../docs/CLI.md#builddevelop-the-native-pass)
+to configure `cmake -S llvm -B build/llvm` with the matching LLVM SDK. Build products
+include `compilerlens-native` and `CompilerLensPasses.so`. The ordinary wheel bundles
+the executable and runs it as a separate process; end users do not need an LLVM SDK.
+
+Source development selects its built executable through `COMPILERLENS_NATIVE` (the README
+uses `build/llvm-conda/compilerlens-native`).
+Both Web Explore and `npm run artifact` invoke the shared analysis pipeline; the latter
+accepts `-- --lineage required` to reject missing or failed native analysis. Generated
+reports feed the frontend's LLVM and assembly lineage views without a PyPI installation.
+
+The [optional IREE hook](iree/README.md) uses IREE's own LLVM build. Its small checkpoint
+test is covered by CTest; a full source-built IREE integration has not been validated.
+
+See the [project overview](../README.md), [CLI reference](../docs/CLI.md), and
+[release checklist](../docs/RELEASING.md) for installation and release status.
diff --git a/llvm/include/compilerlens/Checkpoint.h b/llvm/include/compilerlens/Checkpoint.h
new file mode 100644
index 0000000..086c79d
--- /dev/null
+++ b/llvm/include/compilerlens/Checkpoint.h
@@ -0,0 +1,35 @@
+#pragma once
+// Optional source-build hook. Uses the host IREE build's LLVM, never a foreign plugin.
+#include "compilerlens/Lineage.h"
+#include "llvm/ADT/SmallString.h"
+#include "llvm/IR/Module.h"
+#include "llvm/Support/FileSystem.h"
+#include "llvm/Support/Path.h"
+#include "llvm/Support/raw_ostream.h"
+#include
+
+namespace compilerlens {
+inline void checkpoint(llvm::Module &M, llvm::ModuleAnalysisManager &AM,
+ llvm::StringRef Phase) {
+ const char *Directory = std::getenv("COMPILERLENS_REPORT_DIR");
+ if (!Directory || !*Directory) return;
+ llvm::json::Object Report;
+ llvm::ModulePassManager PM;
+ PM.addPass(LineagePass(&Report));
+ PM.run(M, AM);
+ Report["checkpoint"] = Phase.str();
+ if (auto EC = llvm::sys::fs::create_directories(Directory)) {
+ llvm::errs() << "CompilerLens checkpoint: " << EC.message() << '\n';
+ return;
+ }
+ llvm::SmallString<256> Pattern(Directory), Path;
+ llvm::sys::path::append(Pattern, Phase + "-%%%%%%.json");
+ int FD;
+ if (auto EC = llvm::sys::fs::createUniqueFile(Pattern, FD, Path)) {
+ llvm::errs() << "CompilerLens checkpoint: " << EC.message() << '\n';
+ return;
+ }
+ llvm::raw_fd_ostream OS(FD, true);
+ OS << llvm::json::Value(std::move(Report)) << '\n';
+}
+}
diff --git a/llvm/include/compilerlens/Lineage.h b/llvm/include/compilerlens/Lineage.h
new file mode 100644
index 0000000..bff2cef
--- /dev/null
+++ b/llvm/include/compilerlens/Lineage.h
@@ -0,0 +1,15 @@
+#pragma once
+#include "llvm/IR/PassManager.h"
+#include "llvm/Support/JSON.h"
+#include
+namespace compilerlens {
+llvm::json::Object analyze(llvm::Module &M);
+void printAnnotated(llvm::Module &M, llvm::raw_ostream &OS);
+class LineagePass : public llvm::PassInfoMixin {
+ llvm::json::Object *Result;
+public:
+ explicit LineagePass(llvm::json::Object *Result = nullptr) : Result(Result) {}
+ llvm::PreservedAnalyses run(llvm::Module &M, llvm::ModuleAnalysisManager &);
+ static bool isRequired() { return true; }
+};
+}
diff --git a/llvm/iree/.gitattributes b/llvm/iree/.gitattributes
new file mode 100644
index 0000000..e3aa58b
--- /dev/null
+++ b/llvm/iree/.gitattributes
@@ -0,0 +1,2 @@
+# Unified patches require a space prefix on empty context lines.
+*.patch whitespace=-blank-at-eol
diff --git a/llvm/iree/CompilerLens.cmake b/llvm/iree/CompilerLens.cmake
new file mode 100644
index 0000000..86ab2b5
--- /dev/null
+++ b/llvm/iree/CompilerLens.cmake
@@ -0,0 +1,10 @@
+# Include in compiler/plugins/target/LLVMCPU/CMakeLists.txt after target creation.
+# This compiles with IREE's own LLVM settings; do not link the standalone LLVM SDK.
+if(NOT TARGET iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses)
+ message(FATAL_ERROR "Include CompilerLens.cmake after IREE LLVMIRPasses target creation")
+endif()
+get_filename_component(_compilerlens_llvm "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
+target_sources(iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses PRIVATE
+ "${_compilerlens_llvm}/lib/Lineage.cpp")
+target_include_directories(iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses PRIVATE
+ "${_compilerlens_llvm}/include")
diff --git a/llvm/iree/LLVMIRPasses.patch b/llvm/iree/LLVMIRPasses.patch
new file mode 100644
index 0000000..0d41169
--- /dev/null
+++ b/llvm/iree/LLVMIRPasses.patch
@@ -0,0 +1,28 @@
+--- a/compiler/plugins/target/LLVMCPU/LLVMIRPasses.cpp
++++ b/compiler/plugins/target/LLVMCPU/LLVMIRPasses.cpp
+@@ -6,6 +6,7 @@
+
+ #include "compiler/plugins/target/LLVMCPU/LLVMIRPasses.h"
+
++#include "compilerlens/Checkpoint.h"
+ #include "llvm/Analysis/AliasAnalysis.h"
+ #include "llvm/IR/IRBuilder.h"
+ #include "llvm/IR/LegacyPassManager.h"
+@@ -81,6 +82,8 @@
+ } break;
+ }
+
++ compilerlens::checkpoint(*module, moduleAnalysisManager, "before-optimization");
++
+ if (target.optimizerOptLevel != llvm::OptimizationLevel::O0 ||
+ target.sanitizerKind != SanitizerKind::kNone) {
+ llvm::ModulePassManager modulePassManager;
+@@ -88,6 +91,8 @@
+ passBuilder.buildPerModuleDefaultPipeline(target.optimizerOptLevel);
+ modulePassManager.run(*module, moduleAnalysisManager);
+ }
++
++ compilerlens::checkpoint(*module, moduleAnalysisManager, "after-optimization");
+
+ if (llvm::verifyModule(*module)) {
+ return failure();
diff --git a/llvm/iree/README.md b/llvm/iree/README.md
new file mode 100644
index 0000000..6a9e06b
--- /dev/null
+++ b/llvm/iree/README.md
@@ -0,0 +1,30 @@
+# Optional IREE source-build hook
+
+See the [LLVM component overview](../README.md) for the ordinary packaged analysis
+path and the [CLI guide](../../docs/CLI.md#builddevelop-the-native-pass) for SDK builds.
+
+The release analyzes emitted LLVM snapshots in its private native process. A matching LLVM
+`opt` can also load `CompilerLensPasses.so`. Neither requires an IREE rebuild.
+
+For reporting **inside** IREE, `Checkpoint.h` runs the same New Pass Manager pass and writes
+JSON only when `COMPILERLENS_REPORT_DIR` is set. `LLVMIRPasses.patch` targets IREE commit
+`e4a3b0405d7d23554da26403658d0e8c3c5ecf25` (compiler wheel 3.11.0). It brackets the actual
+optimization block, so O0 also produces before/after reports. Unique filenames avoid
+parallel dispatch collisions. Reports include module and checkpoint; instruction IDs remain
+local to each snapshot.
+
+1. In a checkout of that IREE commit, check/apply `LLVMIRPasses.patch` with `git apply --check`
+ and `git apply`.
+2. After the `iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses` target is created in its CMakeLists,
+ include this directory's `CompilerLens.cmake` using an absolute path. Adjust the target
+ name if your IREE build uses a different target naming convention.
+3. Reconfigure and build IREE with its own pinned LLVM. Never link the LLVM 22 plugin into
+ the LLVM 23 IREE binary.
+4. Set `COMPILERLENS_REPORT_DIR=/absolute/path/checkpoints` for the source-built
+ `iree-compile` process.
+
+The hook itself is compiled and executed by our native CTest, with before/after reporting
+and unchanged IR verified. The patch is checked against the pinned source file. A full
+source-built IREE integration is optional and has **not** been built in this workspace;
+there is no configured IREE source/build tree here. The installed CLI uses the fully tested
+snapshot integration. This is checkpoint coverage reporting, not per-pass loss attribution.
diff --git a/llvm/lib/Lineage.cpp b/llvm/lib/Lineage.cpp
new file mode 100644
index 0000000..c3261dd
--- /dev/null
+++ b/llvm/lib/Lineage.cpp
@@ -0,0 +1,107 @@
+#include "compilerlens/Lineage.h"
+#include "llvm/ADT/DenseMap.h"
+#include "llvm/ADT/SmallPtrSet.h"
+#include "llvm/Config/llvm-config.h"
+#include "llvm/IR/AssemblyAnnotationWriter.h"
+#include "llvm/IR/DebugInfoMetadata.h"
+#include "llvm/IR/Instructions.h"
+#include "llvm/IR/DerivedTypes.h"
+#include "llvm/IR/Module.h"
+#include "llvm/Support/FormattedStream.h"
+#include "llvm/Support/raw_ostream.h"
+using namespace llvm;
+namespace compilerlens {
+namespace {
+using IDs = DenseMap;
+IDs identities(Module &M) {
+ IDs Result;
+ unsigned FIndex = 0;
+ for (auto &F : M) {
+ unsigned BIndex = 0;
+ for (auto &B : F) {
+ unsigned IIndex = 0;
+ for (auto &I : B)
+ Result[&I] = "f" + std::to_string(FIndex) + ":b" + std::to_string(BIndex) + ":i" + std::to_string(IIndex++);
+ ++BIndex;
+ }
+ ++FIndex;
+ }
+ return Result;
+}
+json::Object frame(const DILocation &D) {
+ auto *File = D.getFile();
+ return json::Object{{"file", File ? File->getFilename().str() : ""},
+ {"directory", File ? File->getDirectory().str() : ""},
+ {"line", D.getLine()}, {"column", D.getColumn()},
+ {"discriminator", D.getDiscriminator()}, {"implicit", D.isImplicitCode()},
+ {"scope", D.getScope()->getSubprogram() ? D.getScope()->getSubprogram()->getName().str() : ""}};
+}
+class AnnotationWriter : public AssemblyAnnotationWriter {
+ const IDs &Index;
+public:
+ explicit AnnotationWriter(const IDs &Index) : Index(Index) {}
+ void emitInstructionAnnot(const Instruction *I, formatted_raw_ostream &OS) override {
+ OS << "; compilerlens.id=" << Index.lookup(I) << "\n";
+ }
+};
+}
+json::Object analyze(Module &M) {
+ const auto Index = identities(M);
+ json::Array Functions, Instructions;
+ int64_t Total = 0, Located = 0, LineZero = 0;
+ for (auto &F : M) {
+ int64_t FTotal = 0, FLocated = 0;
+ unsigned BIndex = 0;
+ for (auto &B : F) {
+ for (auto &I : B) {
+ ++Total; ++FTotal;
+ std::string Type;
+ raw_string_ostream TypeStream(Type);
+ I.getType()->print(TypeStream);
+ json::Array Operands, Frames;
+ for (const Use &U : I.operands())
+ if (auto *Operand = dyn_cast(U.get()))
+ Operands.push_back(Index.lookup(Operand));
+ std::string Status = "missing";
+ if (auto D = I.getDebugLoc()) {
+ if (D.getLine()) { ++Located; ++FLocated; Status = "located"; }
+ else { ++LineZero; Status = "line_zero"; }
+ SmallPtrSet Seen;
+ for (const DILocation *At = D.get(); At && Seen.insert(At).second; At = At->getInlinedAt())
+ Frames.push_back(frame(*At));
+ }
+ json::Object Record{{"id", Index.lookup(&I)}, {"function", F.getName().str()},
+ {"block", BIndex}, {"opcode", I.getOpcodeName()}, {"type", Type},
+ {"reads_memory", I.mayReadFromMemory()}, {"writes_memory", I.mayWriteToMemory()},
+ {"operands", std::move(Operands)}, {"frames", std::move(Frames)}, {"location_status", Status}};
+ if (auto *Vector = dyn_cast(I.getType())) {
+ Record["vector_min_lanes"] = Vector->getElementCount().getKnownMinValue();
+ Record["vector_scalable"] = Vector->getElementCount().isScalable();
+ }
+ if (auto *Call = dyn_cast(&I))
+ if (auto *Callee = Call->getCalledFunction()) Record["callee"] = Callee->getName().str();
+ Instructions.push_back(std::move(Record));
+ }
+ ++BIndex;
+ }
+ if (!F.isDeclaration())
+ Functions.push_back(json::Object{{"name", F.getName().str()}, {"instructions", FTotal},
+ {"located", FLocated}, {"dispatch_name", F.getName().contains("dispatch_")}});
+ }
+ return json::Object{{"schema_version", 1}, {"pass_version", "0.2.1"}, {"llvm_version", LLVM_VERSION_STRING},
+ {"module", M.getModuleIdentifier()}, {"instructions", std::move(Instructions)},
+ {"functions", std::move(Functions)},
+ {"coverage", json::Object{{"instructions", Total}, {"debug_locations", Located}, {"line_zero", LineZero}}}};
+}
+void printAnnotated(Module &M, raw_ostream &OS) {
+ auto Index = identities(M);
+ AnnotationWriter Writer(Index);
+ M.print(OS, &Writer);
+}
+PreservedAnalyses LineagePass::run(Module &M, ModuleAnalysisManager &) {
+ auto Report = analyze(M);
+ if (Result) *Result = std::move(Report);
+ else outs() << json::Value(std::move(Report)) << '\n';
+ return PreservedAnalyses::all();
+}
+}
diff --git a/llvm/lib/Plugin.cpp b/llvm/lib/Plugin.cpp
new file mode 100644
index 0000000..732548d
--- /dev/null
+++ b/llvm/lib/Plugin.cpp
@@ -0,0 +1,13 @@
+#include "compilerlens/Lineage.h"
+#include "llvm/Passes/PassBuilder.h"
+#include "llvm/Plugins/PassPlugin.h"
+extern "C" LLVM_ATTRIBUTE_WEAK llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() {
+ return {LLVM_PLUGIN_API_VERSION, "CompilerLensPasses", "0.2.1", [](llvm::PassBuilder &PB) {
+ PB.registerPipelineParsingCallback([](llvm::StringRef Name, llvm::ModulePassManager &MPM,
+ llvm::ArrayRef) {
+ if (Name != "compilerlens-lineage") return false;
+ MPM.addPass(compilerlens::LineagePass());
+ return true;
+ });
+ }};
+}
diff --git a/llvm/tests/check_reports.py b/llvm/tests/check_reports.py
new file mode 100644
index 0000000..61154dd
--- /dev/null
+++ b/llvm/tests/check_reports.py
@@ -0,0 +1,47 @@
+"""Integration contract: standalone, NewPM plugin, annotations and preservation."""
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+binary, opt, plugin, fixtures, checkpoint, llc = sys.argv[1:]
+fixture = Path(fixtures) / 'fixture.ll'
+
+def run(*args, **kwargs):
+ return subprocess.run(list(map(str, args)), check=True, capture_output=True, text=True, **kwargs)
+
+with tempfile.TemporaryDirectory() as tmp:
+ tmp = Path(tmp)
+ output, annotated = tmp / 'report.json', tmp / 'view.ll'
+ run(binary, '--input', fixture, '--output', output, '--annotated-ir', annotated)
+ report = json.loads(output.read_text())
+ assert report['coverage'] == {'instructions': 4, 'debug_locations': 2, 'line_zero': 1}
+ records = report['instructions']
+ assert len(records[0]['frames']) == 2
+ assert records[0]['frames'][1]['line'] == 18
+ assert records[0]['reads_memory'] and records[2]['writes_memory']
+ assert records[1]['operands'] == [records[0]['id']]
+ assert records[3]['frames'] == []
+ assert annotated.read_text().count('; compilerlens.id=') == 4
+ baseline, passed = tmp / 'baseline.ll', tmp / 'passed.ll'
+ run(opt, '-S', fixture, '-o', baseline)
+ result = run(opt, f'-load-pass-plugin={plugin}', '-passes=compilerlens-lineage', '-S', fixture, '-o', passed)
+ assert json.loads(result.stdout) == report
+ assert baseline.read_bytes() == passed.read_bytes(), 'Reporting pass changed IR'
+ # Annotated view remains valid LLVM IR; comments do not alter semantics.
+ run(opt, '-disable-output', annotated)
+ checkpoint_dir = tmp / 'checkpoints'
+ run(checkpoint, fixture, env={**os.environ, 'COMPILERLENS_REPORT_DIR': str(checkpoint_dir)})
+ reports = [json.loads(p.read_text()) for p in checkpoint_dir.glob('*.json')]
+ assert {r['checkpoint'] for r in reports} == {'before-optimization', 'after-optimization'}
+ obj = tmp / 'fixture.o'
+ run(llc, '-filetype=obj', fixture, '-o', obj)
+ dwarf = json.loads(run(binary, '--object', obj, '--section', '.text', '--address', '0').stdout)
+ assert dwarf['frames'] and dwarf['frames'][0]['file'].endswith('dispatch_0.mlir')
+ assert subprocess.run([binary, '--object', str(obj), '--section', '.text', '--address', '0xffffff'], capture_output=True).returncode != 0
+ bad = tmp / 'invalid.ll'
+ bad.write_text('define void @broken() { ret i32 1 }')
+ assert subprocess.run([binary, '--input', str(bad)], capture_output=True).returncode != 0
+print('Native reporting, inline frames, memory/def-use, annotation and read-only plugin checks passed.')
diff --git a/llvm/tests/checkpoint.cpp b/llvm/tests/checkpoint.cpp
new file mode 100644
index 0000000..6768b59
--- /dev/null
+++ b/llvm/tests/checkpoint.cpp
@@ -0,0 +1,21 @@
+#include "compilerlens/Checkpoint.h"
+#include "llvm/IRReader/IRReader.h"
+#include "llvm/Passes/PassBuilder.h"
+#include "llvm/Support/SourceMgr.h"
+int main(int argc, char **argv) {
+ if (argc != 2) return 2;
+ llvm::LLVMContext C;
+ llvm::SMDiagnostic Error;
+ auto M = llvm::parseIRFile(argv[1], Error, C);
+ if (!M) return 1;
+ std::string Before, After;
+ llvm::raw_string_ostream BS(Before), AS(After);
+ M->print(BS, nullptr);
+ llvm::ModuleAnalysisManager AM;
+ llvm::PassBuilder PB;
+ PB.registerModuleAnalyses(AM);
+ compilerlens::checkpoint(*M, AM, "before-optimization");
+ compilerlens::checkpoint(*M, AM, "after-optimization");
+ M->print(AS, nullptr);
+ return Before == After ? 0 : 1;
+}
diff --git a/llvm/tests/fixture.ll b/llvm/tests/fixture.ll
new file mode 100644
index 0000000..b5250c5
--- /dev/null
+++ b/llvm/tests/fixture.ll
@@ -0,0 +1,22 @@
+source_filename = "fixture.mlir"
+target triple = "x86_64-unknown-linux-gnu"
+define i32 @dispatch_0(ptr %p, i32 %x) !dbg !4 {
+entry:
+ %v = load i32, ptr %p, !dbg !8
+ %sum = add i32 %v, %x, !dbg !9
+ store i32 %sum, ptr %p, !dbg !10
+ ret i32 %sum
+}
+!llvm.dbg.cu = !{!0}
+!llvm.module.flags = !{!2, !3}
+!0 = distinct !DICompileUnit(language: DW_LANG_C, file: !1, producer: "CompilerLens fixture", isOptimized: true, runtimeVersion: 0, emissionKind: FullDebug)
+!1 = !DIFile(filename: "dispatch_0.mlir", directory: "/captured")
+!2 = !{i32 2, !"Dwarf Version", i32 4}
+!3 = !{i32 2, !"Debug Info Version", i32 3}
+!4 = distinct !DISubprogram(name: "dispatch_0", scope: !1, file: !1, line: 1, type: !5, scopeLine: 1, spFlags: DISPFlagDefinition, unit: !0)
+!5 = !DISubroutineType(types: !6)
+!6 = !{}
+!7 = !DILocation(line: 18, column: 8, scope: !4)
+!8 = !DILocation(line: 12, column: 4, scope: !4, inlinedAt: !7)
+!9 = !DILocation(line: 0, scope: !4)
+!10 = !DILocation(line: 20, column: 2, scope: !4)
diff --git a/llvm/tools/main.cpp b/llvm/tools/main.cpp
new file mode 100644
index 0000000..51b19e4
--- /dev/null
+++ b/llvm/tools/main.cpp
@@ -0,0 +1,112 @@
+#include "compilerlens/Lineage.h"
+#include "llvm/Config/llvm-config.h"
+#include "llvm/DebugInfo/DWARF/DWARFContext.h"
+#include "llvm/IR/DiagnosticInfo.h"
+#include "llvm/IR/DiagnosticPrinter.h"
+#include "llvm/IR/Module.h"
+#include "llvm/IR/Verifier.h"
+#include "llvm/IRReader/IRReader.h"
+#include "llvm/Object/ObjectFile.h"
+#include "llvm/Passes/PassBuilder.h"
+#include "llvm/Support/CommandLine.h"
+#include "llvm/Support/FileSystem.h"
+#include "llvm/Support/InitLLVM.h"
+#include "llvm/Support/SourceMgr.h"
+#include "llvm/Support/raw_ostream.h"
+using namespace llvm;
+static cl::opt Input("input", cl::desc("LLVM textual IR or bitcode"));
+static cl::opt Output("output", cl::init("-"), cl::desc("JSON output path"));
+static cl::opt Annotated("annotated-ir", cl::desc("Annotated LLVM display view"));
+static cl::opt Object("object", cl::desc("Object file for DWARF lookup"));
+static cl::opt ListSections("list-sections", cl::desc("List object sections without an address lookup"));
+static cl::opt Section("section", cl::desc("Object section name"));
+static cl::opt Address("address", cl::desc("Section-relative offset (decimal or 0xhex)"));
+static bool DiagnosticFailure = false;
+static void diagnose(const DiagnosticInfo *DI, void *) {
+ DiagnosticPrinterRawOStream Printer(errs());
+ DI->print(Printer); errs() << '\n';
+ if (DI->getSeverity() == DS_Error || DI->getSeverity() == DS_Warning) DiagnosticFailure = true;
+}
+static Expected objectReport() {
+ auto Binary = object::createBinary(Object);
+ if (!Binary) return Binary.takeError();
+ auto *Obj = dyn_cast(Binary->getBinary());
+ if (!Obj) return createStringError(inconvertibleErrorCode(), "Expected an object file");
+ if (ListSections) {
+ json::Array Sections;
+ for (auto S : Obj->sections()) {
+ auto Name = S.getName();
+ if (!Name) return Name.takeError();
+ Sections.push_back(json::Object{{"name", Name->str()}, {"size", S.getSize()},
+ {"address", S.getAddress()}, {"executable", S.isText()}});
+ }
+ return json::Object{{"schema_version", 1}, {"object", Object.getValue()}, {"sections", std::move(Sections)}};
+ }
+ uint64_t Offset = 0;
+ if (Address.empty() || StringRef(Address).getAsInteger(0, Offset))
+ return createStringError(inconvertibleErrorCode(), "Invalid section-relative address");
+ for (auto S : Obj->sections()) {
+ auto Name = S.getName();
+ if (!Name) return Name.takeError();
+ if (*Name != Section) continue;
+ if (Offset >= S.getSize()) return createStringError(inconvertibleErrorCode(), "Address outside section");
+ auto DW = DWARFContext::create(*Obj);
+ DILineInfoSpecifier Spec(DILineInfoSpecifier::FileLineInfoKind::AbsoluteFilePath,
+ DILineInfoSpecifier::FunctionNameKind::LinkageName);
+ auto Info = DW->getInliningInfoForAddress({S.getAddress() + Offset, S.getIndex()}, Spec);
+ json::Array Frames;
+ for (uint32_t I = 0; I < Info.getNumberOfFrames(); ++I) {
+ auto &F = Info.getFrame(I);
+ Frames.push_back(json::Object{{"file", F.FileName}, {"line", F.Line}, {"column", F.Column},
+ {"function", F.FunctionName}});
+ }
+ return json::Object{{"schema_version", 1}, {"llvm_version", LLVM_VERSION_STRING},
+ {"object", Object.getValue()}, {"section", Section.getValue()}, {"offset", Offset},
+ {"frames", std::move(Frames)}};
+ }
+ return createStringError(inconvertibleErrorCode(), "Unknown object section");
+}
+int main(int argc, char **argv) {
+ InitLLVM Init(argc, argv);
+ cl::SetVersionPrinter([](raw_ostream &OS) {
+ OS << "compilerlens-native 0.2.1 LLVM " << LLVM_VERSION_STRING << " protocol 1\n";
+ });
+ cl::ParseCommandLineOptions(argc, argv, "CompilerLens native lineage analyzer\n");
+ json::Object Report;
+ if (!Object.empty()) {
+ if (!Input.empty() || (!ListSections && Section.empty())) { errs() << "--object requires --section and no --input\n"; return 2; }
+ auto Result = objectReport();
+ if (!Result) { errs() << toString(Result.takeError()) << '\n'; return 1; }
+ Report = std::move(*Result);
+ } else {
+ if (Input.empty()) { errs() << "--input is required\n"; return 2; }
+ LLVMContext Context;
+ Context.setDiagnosticHandlerCallBack(diagnose);
+ SMDiagnostic Error;
+ auto M = parseIRFile(Input, Error, Context);
+ if (!M) { Error.print(argv[0], errs()); return 1; }
+ if (DiagnosticFailure || verifyModule(*M, &errs())) return 1;
+ LoopAnalysisManager LAM;
+ FunctionAnalysisManager FAM;
+ CGSCCAnalysisManager CGAM;
+ ModuleAnalysisManager MAM;
+ PassBuilder PB;
+ PB.registerModuleAnalyses(MAM); PB.registerCGSCCAnalyses(CGAM);
+ PB.registerFunctionAnalyses(FAM); PB.registerLoopAnalyses(LAM);
+ PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
+ ModulePassManager MPM;
+ MPM.addPass(compilerlens::LineagePass(&Report));
+ MPM.run(*M, MAM);
+ if (!Annotated.empty()) {
+ std::error_code EC;
+ raw_fd_ostream OS(Annotated, EC);
+ if (EC) { errs() << EC.message() << '\n'; return 1; }
+ compilerlens::printAnnotated(*M, OS);
+ }
+ }
+ std::error_code EC;
+ raw_fd_ostream OS(Output, EC);
+ if (EC) { errs() << EC.message() << '\n'; return 1; }
+ OS << json::Value(std::move(Report)) << '\n';
+ return 0;
+}
diff --git a/models/__init__.py b/models/__init__.py
index e69de29..614d6a4 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -0,0 +1,3 @@
+"""Source-checkout compatibility; installed API is compilerlens.models."""
+from compilerlens import models as _package
+__path__ = _package.__path__
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..d37aaf1
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,32 @@
+[build-system]
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "compilerlens"
+version = "0.2.1"
+description = "Inspect model compilation and trace compiler-recorded lineage into LLVM and assembly"
+readme = "README.md"
+license = "Apache-2.0"
+license-files = ["LICENSE"]
+requires-python = ">=3.10"
+dependencies = [
+ "rich>=13,<16", "numpy>=2,<3", "fastapi>=0.115,<1", "uvicorn>=0.30,<1",
+ "torch==2.13.0", "iree-base-compiler==3.11.0", "iree-base-runtime==3.11.0",
+ "iree-turbine==3.9.0", "transformers==5.16.1", "huggingface-hub>=1,<2"
+]
+
+[project.urls]
+Repository = "https://github.com/Soumyaworks/CompilerLens"
+Issues = "https://github.com/Soumyaworks/CompilerLens/issues"
+Documentation = "https://github.com/Soumyaworks/CompilerLens#readme"
+
+[project.scripts]
+compilerlens = "compilerlens.cli:main"
+
+[tool.setuptools.packages.find]
+include = ["compilerlens*"]
+exclude = ["compilerlens._web*", "compilerlens._bin*"]
+
+[tool.setuptools.package-data]
+compilerlens = ["_bin/*", "_bin/lib/*", "_bin/licenses/*", "_web/*", "_web/assets/*"]
diff --git a/scripts/build_release.py b/scripts/build_release.py
new file mode 100644
index 0000000..d9c4433
--- /dev/null
+++ b/scripts/build_release.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Build native analyzer, existing web app and a platform-specific pip wheel.
+
+Configure build/llvm with a matching LLVM SDK first, or pass --llvm-dir.
+No dependency downloads: install frontend/node and Python build dependencies first.
+"""
+import argparse
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def run(*args, cwd=ROOT):
+ print('+', ' '.join(map(str, args)), flush=True)
+ subprocess.run(list(map(str, args)), cwd=cwd, check=True)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--llvm-dir')
+ parser.add_argument('--cmake', default='cmake')
+ parser.add_argument('--assets-only', action='store_true')
+ args = parser.parse_args()
+ if args.llvm_dir:
+ run(args.cmake, '-S', 'llvm', '-B', 'build/llvm', '-DCMAKE_BUILD_TYPE=Release', f'-DLLVM_DIR={args.llvm_dir}')
+ if not (ROOT / 'build/llvm/CMakeCache.txt').exists(): parser.error('Configure build/llvm first or supply --llvm-dir.')
+ run(args.cmake, '--build', 'build/llvm', '--parallel', '2')
+ run(str(Path(args.cmake).with_name("ctest")) if "/" in args.cmake else "ctest", "--test-dir", "build/llvm", "--output-on-failure")
+ run(args.cmake, '--install', 'build/llvm', '--prefix', ROOT, '--strip')
+ binary = ROOT / 'compilerlens/_bin/compilerlens-native'
+ # LLVM is statically linked. Bundle zstd (outside the manylinux baseline) and
+ # libz so the private executable needs only the host C/C++ runtime libraries.
+ libs = binary.parent / 'lib'
+ libs.mkdir(exist_ok=True)
+ linked = subprocess.check_output(['ldd', str(binary)], text=True)
+ for line in linked.splitlines():
+ fields = line.split()
+ if fields and fields[0] in ('libzstd.so.1', 'libz.so.1'):
+ if Path(fields[2]).resolve() != (libs / fields[0]).resolve():
+ shutil.copy2(fields[2], libs / fields[0])
+ licenses = binary.parent / 'licenses'
+ licenses.mkdir(exist_ok=True)
+ for name, path in [('zstd', '/usr/share/doc/libzstd1/copyright'), ('zlib', '/usr/share/doc/zlib1g/copyright')]:
+ if Path(path).exists(): shutil.copy2(path, licenses / (name + '.txt'))
+ # LLVM license is maintained alongside the native source, and bundled in wheels.
+ shutil.copy2(ROOT / 'llvm/LLVM-LICENSE.txt', licenses / 'LLVM.txt')
+ run('npm', 'exec', '--', 'tsc', '--noEmit', cwd=ROOT / 'frontend')
+ run('npm', 'exec', '--', 'vite', 'build', '--config', 'vite.release.config.ts', cwd=ROOT / 'frontend')
+ if not args.assets_only:
+ run(sys.executable, '-m', 'pip', 'wheel', '.', '--no-deps', '--no-build-isolation', '--wheel-dir', 'dist')
+
+
+if __name__ == '__main__': main()
diff --git a/scripts/check_installed.py b/scripts/check_installed.py
new file mode 100644
index 0000000..ea1a2c6
--- /dev/null
+++ b/scripts/check_installed.py
@@ -0,0 +1,54 @@
+"""Smoke test an installed wheel, with the server/browser in one process namespace."""
+import argparse
+import json
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import time
+import urllib.request
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('python', type=Path)
+ parser.add_argument('run', type=Path)
+ parser.add_argument('--browser', action='store_true')
+ parser.add_argument('--browsers-path', type=Path)
+ args = parser.parse_args()
+ source = Path(__file__).resolve().parents[1]
+ env = {**os.environ, 'PYTHONPATH': '', 'PATH': f'{args.python.parent}:/usr/bin:/bin'}
+ with tempfile.TemporaryDirectory() as cwd:
+ env['COMPILERLENS_WORKSPACE'] = str(Path(cwd) / 'workspace')
+ command = [str(args.python), '-m', 'compilerlens']
+ doctor = json.loads(subprocess.check_output(command + ['doctor', '--format', 'json'], cwd=cwd, env=env))
+ assert doctor['ready'], doctor
+ assert str(source) not in doctor['native']['path']
+ print('Installed doctor:', doctor['native']['version'], flush=True)
+ trace = json.loads(subprocess.check_output(command + ['trace', str(args.run), '--module', 'model', '--to', 'asm', '--format', 'json'], cwd=cwd, env=env))
+ assert trace['matches'], 'No installed assembly source matches'
+ print('Installed trace:', len(trace['matches']), 'instructions', flush=True)
+ server = subprocess.Popen(command + ['view', str(args.run), '--port', '8768'], cwd=cwd, env=env)
+ try:
+ url = 'http://127.0.0.1:8768'
+ for _ in range(100):
+ if server.poll() is not None: raise RuntimeError('Viewer exited early')
+ try:
+ with urllib.request.urlopen(url + '/health', timeout=1) as response:
+ assert response.status == 200
+ break
+ except OSError: time.sleep(0.1)
+ else: raise RuntimeError('Viewer failed to start')
+ for route in ('/', '/options', '/artifacts/index.json'):
+ with urllib.request.urlopen(url + route, timeout=5) as response: assert response.status == 200
+ print('Installed HTTP routes passed', flush=True)
+ if args.browser:
+ subprocess.run(['node', str(source / 'scripts/check_viewer.mjs'), url], check=True, cwd=source, timeout=60,
+ env={**os.environ, **({'PLAYWRIGHT_BROWSERS_PATH': str(args.browsers_path)} if args.browsers_path else {})})
+ finally:
+ server.terminate()
+ try: server.wait(timeout=10)
+ except subprocess.TimeoutExpired: server.kill(); server.wait()
+
+
+if __name__ == '__main__': main()
diff --git a/scripts/check_multimodal_viewer.mjs b/scripts/check_multimodal_viewer.mjs
new file mode 100644
index 0000000..3f33bf7
--- /dev/null
+++ b/scripts/check_multimodal_viewer.mjs
@@ -0,0 +1,43 @@
+// Browser smoke check: URL [workload title] [--vit]. Defaults to the tiny_clip demo.
+import assert from 'node:assert/strict';
+import {createRequire} from 'node:module';
+const require = createRequire(new URL('../frontend/package.json', import.meta.url));
+const {chromium} = require('playwright');
+const browser = await chromium.launch({headless: true, args: ['--no-sandbox']});
+const page = await browser.newPage({viewport: {width: 1440, height: 950}});
+const failures = [];
+const title = process.argv[3] ?? 'tiny_clip';
+const vit = process.argv.includes('--vit');
+page.on('pageerror', error => failures.push(String(error)));
+page.on('requestfailed', request => failures.push(request.url()));
+try {
+ await page.goto(process.argv[2], {waitUntil: 'networkidle'});
+ await page.locator('.workload-card').first().waitFor();
+ assert.equal(await page.locator('#landing-lineage').count(), 0);
+ const card = page.locator('.workload-card').filter({hasText: title}).first();
+ await card.locator('.workload-card-open').click();
+ await page.locator('.architecture-input-terminal').waitFor();
+ assert.match(await page.locator('.architecture-input-terminal').innerText(),
+ vit ? /Synthetic inputs.*Image tensor/s : /Synthetic inputs.*Image \+ text \+ mask/s);
+ assert.match(await page.locator('.architecture-output-terminal').innerText(),
+ vit ? /last_hidden_state/ : /Embeddings \+ similarity/);
+ assert.match(await page.locator('.architecture-header-facts').innerText(), vit ? /image/ : /image \+ text/);
+ if (title === 'tiny_clip' || title === 'tiny_vit') {
+ assert.match(await page.locator('.architecture-header-facts').innerText(), /Random/);
+ } else {
+ assert.doesNotMatch(await page.locator('.architecture-header-facts').innerText(), /Random/);
+ }
+ if (!vit) assert.equal(await page.locator('.architecture-flow-connector').count(), 0);
+ await page.screenshot({path: '/tmp/compilerlens-multimodal-desktop.png'});
+ await page.setViewportSize({width: 390, height: 844});
+ const inputBox = await page.locator('.architecture-input-terminal').boundingBox();
+ assert.ok(inputBox.x >= 0 && inputBox.x + inputBox.width <= 390, 'Mobile inputs are clipped');
+ await page.screenshot({path: '/tmp/compilerlens-multimodal-mobile.png'});
+ await page.setViewportSize({width: 1440, height: 950});
+ await page.locator('.architecture-pipeline-button').click();
+ await page.locator('.monaco-editor .view-line').first().waitFor({timeout: 30000});
+ assert.deepEqual(failures, []);
+ console.log('Multimodal viewer: labels, clean search, architecture, and compiler workspace passed.');
+} finally {
+ await browser.close();
+}
diff --git a/scripts/check_viewer.mjs b/scripts/check_viewer.mjs
new file mode 100644
index 0000000..780ca89
--- /dev/null
+++ b/scripts/check_viewer.mjs
@@ -0,0 +1,20 @@
+// Existing UI smoke check against the installed package server.
+import {createRequire} from 'node:module';
+const require = createRequire(new URL('../frontend/package.json', import.meta.url));
+const {chromium} = require('playwright');
+const browser = await chromium.launch({headless: true, args: ['--no-sandbox']});
+const page = await browser.newPage({viewport: {width: 1600, height: 950}});
+const failures = [];
+page.on('pageerror', error => failures.push(String(error)));
+page.on('requestfailed', request => failures.push(request.url()));
+try {
+ await page.goto(process.argv[2], {waitUntil: 'networkidle'});
+ await page.locator('.workload-card').first().waitFor();
+ await page.locator('.workload-card-open').first().click();
+ await page.locator('.architecture-pipeline-button').click();
+ await page.locator('.monaco-editor .view-line').first().waitFor({timeout: 30000});
+ if (await page.locator('.golden-layout-container .pane').count() < 2) throw Error('Workspace panes missing');
+ if (failures.length) throw Error(failures.join('\n'));
+ await page.screenshot({path: '/tmp/compilerlens-installed-viewer.png'});
+ console.log('Installed viewer: workload cards, workspace, Monaco and workers passed.');
+} finally { await browser.close(); }
diff --git a/scripts/compile_hf_model.py b/scripts/compile_hf_model.py
index a129fe0..9651ac2 100644
--- a/scripts/compile_hf_model.py
+++ b/scripts/compile_hf_model.py
@@ -10,7 +10,7 @@
directory contains a model_info.json, which is what makes ingest pick the model up as a
workload -- so the next step is just `npm run artifact` from frontend/.
-Run from the repo root with the venv active.
+Run from the repo root with the source Conda environment active.
"""
from __future__ import annotations
@@ -26,14 +26,14 @@
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
-from backend.compiler.runner import ( # noqa: E402
+from compilerlens.backend.compiler.runner import ( # noqa: E402
TRIM_CODEGEN_PASSES,
TRIM_ELIDE_ATTRS,
CompilerRunner,
RunConfig,
)
-from models.detect import UnsupportedModelError, detect # noqa: E402
-from models.hf_wrapper import wrap, wrapper_source # noqa: E402
+from compilerlens.models.detect import UnsupportedModelError, detect # noqa: E402
+from compilerlens.models.hf_wrapper import wrap, wrapper_source # noqa: E402
def main() -> int:
@@ -41,6 +41,7 @@ def main() -> int:
parser.add_argument("model_id", help="HuggingFace model id, e.g. prajjwal1/bert-tiny")
parser.add_argument("--revision", default=None, help="pin a specific commit (default: current main sha)")
parser.add_argument("--seq-len", type=int, default=32, help="sequence length to trace with (default: 32)")
+ parser.add_argument("--seed", type=int, default=0, help="seed for synthetic inputs (default: 0)")
parser.add_argument("--out-dir", type=Path, default=None, help="destination (default: examples/)")
parser.add_argument("--dry-run", action="store_true", help="detect and report, then stop before compiling")
parser.add_argument(
@@ -59,10 +60,16 @@ def main() -> int:
print(f"{detected.model_id}")
print(f" revision {detected.revision}")
- print(f" type {detected.model_type} ({'causal decoder' if detected.causal else 'bidirectional encoder'})")
- print(f" vocab {detected.vocab_size}")
- print(f" seq_len {detected.seq_len}")
- print(f" output .{detected.output_attr}")
+ from compilerlens.models.adapters import adapter_for
+ adapter = adapter_for(detected)
+ print(f" type {detected.model_type} ({adapter.name} adapter)")
+ print(f" modalities {', '.join(adapter.modalities)}")
+ if detected.vocab_size:
+ print(f" vocab {detected.vocab_size}")
+ print(f" seq_len {detected.seq_len}")
+ if detected.input_config:
+ print(f" profile {detected.input_config}")
+ print(f" outputs {', '.join(adapter.output_names or (detected.output_attr,))}")
print(f" detected by {detected.detected_via}")
if args.dry_run:
@@ -73,6 +80,8 @@ def main() -> int:
print("\nloading model...")
try:
+ import torch
+ torch.manual_seed(args.seed)
module, example_inputs, model_info = wrap(detected)
except Exception as exc:
print(f"error: could not load/wrap '{detected.model_id}': {exc}", file=sys.stderr)
@@ -93,7 +102,7 @@ def main() -> int:
# The source stage the generated spec shows. Written after the run so a failed compile
# does not leave a directory that looks complete.
- (out_dir / "source.py").write_text(wrapper_source(detected))
+ (out_dir / "source.py").write_text(wrapper_source(detected, seed=args.seed))
# The untrimmed module the compiler read. Only useful during the run; keeping it would
# double the directory size for no benefit to the viewer.
diff --git a/scripts/hf_checkpoints.json b/scripts/hf_checkpoints.json
new file mode 100644
index 0000000..93a365e
--- /dev/null
+++ b/scripts/hf_checkpoints.json
@@ -0,0 +1,20 @@
+{
+ "vit-tiny": {
+ "model_id": "WinKawaks/vit-tiny-patch16-224",
+ "revision": "77d1485af66b34d4ed0fe95dbb0c60c7496f950b",
+ "adapter": "vit",
+ "seq_len": 16,
+ "image_size": 224,
+ "task": "image-encoder",
+ "parameters": 5524416
+ },
+ "tinyclip": {
+ "model_id": "wkcn/TinyCLIP-ViT-8M-16-Text-3M-YFCC15M",
+ "revision": "a2a8c6eaa2549ad66eb7c31b85022bf58273a26c",
+ "adapter": "clip",
+ "seq_len": 16,
+ "image_size": 224,
+ "task": "image-text-similarity",
+ "parameters": 23446017
+ }
+}
diff --git a/scripts/validate_hf_models.py b/scripts/validate_hf_models.py
new file mode 100644
index 0000000..459572f
--- /dev/null
+++ b/scripts/validate_hf_models.py
@@ -0,0 +1,191 @@
+"""Validate pinned pretrained checkpoints through CLI, Web Explore or Playground.
+
+Example (from source, cache location controlled by HF_HOME):
+ python scripts/validate_hf_models.py vit-tiny --out build/vit-check --offline
+ python scripts/validate_hf_models.py tinyclip --mode web --out build/clip-check
+
+Uses real model loaders and compiler processes, not mocks. Each output directory must
+be new. The temporary API binds only to loopback and is always stopped. --installed
+runs outside the checkout; use --python to select the wheel's environment.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from pathlib import Path
+import resource
+import signal
+import socket
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def stop_group(process):
+ """Stop only this validator's session, including any compiler descendants."""
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ try:
+ process.wait(timeout=15)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait()
+
+
+def request(url, body=None):
+ data = json.dumps(body).encode() if body is not None else None
+ req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
+ with urllib.request.urlopen(req, timeout=15) as response:
+ return json.load(response)
+
+
+def run(args, checkpoint, out, env):
+ command = [str(args.python), '-m', 'compilerlens']
+ if args.mode == 'cli':
+ run_dir = out / 'capture'
+ cmd = command + ['compile', checkpoint['model_id'], '--revision', checkpoint['revision'],
+ '--seq-len', str(checkpoint['seq_len']), '--out', str(run_dir),
+ '--lineage', args.lineage, '--format', 'json']
+ if args.offline:
+ cmd.append('--offline')
+ with (out / 'compile.log').open('w') as log:
+ process = subprocess.Popen(cmd, cwd=out, env=env, stdout=log,
+ stderr=subprocess.STDOUT, start_new_session=True)
+ try:
+ if process.wait(timeout=args.timeout):
+ raise RuntimeError('CLI capture failed; see compile.log.')
+ finally:
+ stop_group(process)
+ return run_dir
+
+ # No connection to an existing user's API or saved workload library.
+ with socket.socket() as sock:
+ sock.bind(('127.0.0.1', 0))
+ port = sock.getsockname()[1]
+ url = f'http://127.0.0.1:{port}'
+ with (out / 'api.log').open('w') as log:
+ server = subprocess.Popen([str(args.python), '-m', 'uvicorn',
+ 'compilerlens.backend.api.app:app', '--host', '127.0.0.1', '--port', str(port)],
+ cwd=out, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
+ try:
+ deadline = time.monotonic() + args.timeout
+ while True:
+ if server.poll() is not None:
+ raise RuntimeError('Validation API exited; see api.log.')
+ try:
+ request(url + '/options')
+ break
+ except (OSError, urllib.error.URLError):
+ if time.monotonic() > deadline:
+ raise TimeoutError('Validation API startup timed out.')
+ time.sleep(0.2)
+ body = {key: checkpoint[key] for key in ('model_id', 'revision', 'seq_len')}
+ body['offline'] = args.offline
+ if args.mode == 'web':
+ body['lineage'] = args.lineage
+ else:
+ body.update(stages=['input', 'executable-targets'], want_asm=True)
+ job_id = request(url + ('/explore' if args.mode == 'web' else '/compile'), body)['job_id']
+ while True:
+ job = request(url + f'/compile/{job_id}')
+ if job['status'] != 'running':
+ break
+ if time.monotonic() > deadline:
+ raise TimeoutError('Compilation timed out; see api.log.')
+ time.sleep(0.5)
+ if job['status'] != 'done':
+ raise RuntimeError(job['error'])
+ (out / 'job.json').write_text(json.dumps(job, indent=2) + '\n')
+ return out / 'workspace' / ('runs' if args.mode == 'web' else 'jobs') / job_id
+ finally:
+ stop_group(server)
+
+
+def main():
+ checkpoints = json.loads((Path(__file__).with_name('hf_checkpoints.json')).read_text())
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('checkpoint', choices=checkpoints)
+ parser.add_argument('--mode', choices=('cli', 'web', 'playground'), default='cli')
+ parser.add_argument('--out', type=Path, required=True)
+ parser.add_argument('--python', type=Path, default=Path(sys.executable))
+ parser.add_argument('--offline', action='store_true')
+ parser.add_argument('--installed', action='store_true')
+ parser.add_argument('--lineage', choices=('auto', 'required', 'off'), default='auto')
+ parser.add_argument('--timeout', type=int, default=1200)
+ args = parser.parse_args()
+ # Do not resolve a venv's Python symlink: its path selects that environment.
+ args.python = args.python.expanduser().absolute()
+ out = args.out.resolve()
+ out.mkdir(parents=True, exist_ok=False)
+ env = {**os.environ, 'COMPILERLENS_WORKSPACE': str(out / 'workspace'),
+ 'HF_HUB_DISABLE_PROGRESS_BARS': '1', 'OMP_NUM_THREADS': '2'}
+ if not args.installed:
+ env['PYTHONPATH'] = str(ROOT)
+ checkpoint = checkpoints[args.checkpoint]
+ report = {'status': 'failed', 'mode': args.mode, 'checkpoint': checkpoint}
+ started = time.monotonic()
+ try:
+ installation = json.loads(subprocess.check_output([str(args.python), '-c',
+ 'import json, compilerlens; from compilerlens.services import versions; '
+ 'print(json.dumps(dict(package_path=compilerlens.__file__, versions=versions())))'],
+ env=env, cwd=out, text=True))
+ location = installation['package_path']
+ if args.installed and Path(location).is_relative_to(ROOT / 'compilerlens'):
+ raise RuntimeError('--installed imported the source checkout, not an installed wheel.')
+ report.update(installation)
+ run_dir = run(args, checkpoint, out, env)
+ if args.mode == 'playground':
+ info = json.loads((out / 'job.json').read_text())['model_info']
+ else:
+ info = json.loads((run_dir / 'model_info.json').read_text())
+ for key in ('model_id', 'revision', 'adapter', 'task'):
+ if info[key] != checkpoint[key]:
+ raise RuntimeError(f'Checkpoint {key} differs: {info[key]}')
+ if info['param_count'] != checkpoint['parameters']:
+ raise RuntimeError(f"Parameter count differs: {info['param_count']}")
+ verification = json.loads((run_dir / 'verification.json').read_text())
+ if verification['status'] != 'passed':
+ raise RuntimeError(f'Output verification did not pass: {verification}')
+ report.update(run=str(run_dir), model_info=info, verification=verification)
+ if args.mode != 'playground':
+ artifact = json.loads((run_dir / 'artifact.json').read_text())
+ sidecar = json.loads((run_dir / 'lineage.json').read_text())
+ if not any(s['language'] == 'asm' for s in artifact['stages']):
+ raise RuntimeError('No assembly stage was captured.')
+ report.update(architecture=artifact['architecture']['mapping_status'],
+ stage_count=len(artifact['stages']), lineage=sidecar['status'])
+ if report['architecture'] != 'exact':
+ raise RuntimeError('Module-to-Torch mapping is not exact.')
+ coverage = {s['name']: sidecar['stages'].get(s['id'], {}).get('coverage', {})
+ for s in artifact['stages'] if s['language'] in ('llvm', 'asm')}
+ report['coverage'] = coverage
+ if args.lineage == 'required' and any(
+ not any(sidecar['stages'].get(s['id'], {}).get('coverage', {}).get('source_associated', 0)
+ for s in artifact['stages'] if s['language'] == language)
+ for language in ('llvm', 'asm')):
+ raise RuntimeError('Required lineage has no source links in LLVM or assembly.')
+ if args.mode == 'web':
+ published = out / 'workspace/frontend/public/artifacts' / f"{artifact['compilation_id']}.json"
+ if not published.is_file():
+ raise RuntimeError('Web Explore did not publish its artifact.')
+ report['status'] = 'passed'
+ except Exception as exc:
+ report['error'] = f'{type(exc).__name__}: {exc}'
+ finally:
+ report['wall_seconds'] = round(time.monotonic() - started, 2)
+ report['child_peak_rss_kib'] = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
+ report['output_bytes'] = sum(p.stat().st_size for p in out.rglob('*') if p.is_file())
+ (out / 'validation.json').write_text(json.dumps(report, indent=2) + '\n')
+ print(json.dumps(report, indent=2))
+ return 0 if report['status'] == 'passed' else 1
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..162181f
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,27 @@
+"""Platform wheel: private native executable, independent of CPython's ABI."""
+from pathlib import Path
+from setuptools import setup, Distribution
+from setuptools.command.bdist_wheel import bdist_wheel
+from setuptools.command.build_py import build_py
+
+
+class PlatformDistribution(Distribution):
+ def has_ext_modules(self):
+ return True
+
+
+class BuildPackage(build_py):
+ def run(self):
+ for name in ('compilerlens/_bin/compilerlens-native', 'compilerlens/_web/index.html'):
+ if not Path(name).is_file():
+ raise RuntimeError(f'Missing {name}. Prepare native/web assets with python scripts/build_release.py --assets-only before building a wheel.')
+ super().run()
+
+
+class PlatformWheel(bdist_wheel):
+ def get_tag(self):
+ _, _, platform = super().get_tag()
+ return 'py3', 'none', platform
+
+
+setup(distclass=PlatformDistribution, cmdclass={'bdist_wheel': PlatformWheel, 'build_py': BuildPackage})
diff --git a/tests/test_architecture.py b/tests/test_architecture.py
index b4aa82f..a4f8c7d 100644
--- a/tests/test_architecture.py
+++ b/tests/test_architecture.py
@@ -7,9 +7,9 @@
import torch
-from ingest.architecture import build_architecture
-from ingest.schema import Operation, Stage
-from models.architecture import capture_architecture
+from compilerlens.ingest.architecture import build_architecture
+from compilerlens.ingest.schema import Operation, Stage
+from compilerlens.models.architecture import capture_architecture
class TinyModule(torch.nn.Module):
diff --git a/tests/test_cli_lineage.py b/tests/test_cli_lineage.py
new file mode 100644
index 0000000..f5d6d23
--- /dev/null
+++ b/tests/test_cli_lineage.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from compilerlens.lineage import SourceIndex, assembly_locations, analyze_files
+from compilerlens.ingest.mlir_loc import source_location_sets_by_line
+from compilerlens.queries import trace_report, diff_report, inspect_report
+from compilerlens.services import capture, import_dump, save_inputs
+from compilerlens.storage import atomic_json, load_run
+
+
+class SourceTests(unittest.TestCase):
+ def test_fused_origins_preserve_columns(self):
+ text = '%0 = arith.addi %a, %b : i32 loc(fused[#loc1, #loc2])\n#loc1 = loc("a.mlir":3:4)\n#loc2 = loc("a.mlir":3:8)'
+ self.assertEqual(set(source_location_sets_by_line(text)[1]), {'a.mlir:3:4', 'a.mlir:3:8'})
+
+ def test_asm_resets_zero_section_and_function(self):
+ text = '.file 1 "/captured space" "dispatch_0.mlir"\n.loc 1 18 8 discriminator 4\n add %a, %b\n.Ltmp:\n ret\n.loc 1 0 0\n ret\n.loc 1 18 8\n.section .data\n ret\n.loc 1 18 8\n.type helper,@function\n ret'
+ locations = assembly_locations(text)
+ self.assertEqual(set(locations), {3, 5})
+ self.assertEqual(locations[3]['directory'], '/captured space')
+ self.assertEqual(locations[3]['discriminator'], 4)
+
+ def test_duplicate_dispatch_and_helper_never_guess(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ (root / 'llvm').mkdir(); (root / 'mlir').mkdir()
+ anchor = root / 'mlir/ir_00_torch_input.mlir'
+ anchor.write_text('a\nb\nc\n')
+ atomic_json(root / 'manifest.json', {'files': {anchor.name: str(anchor)}})
+ for prefix in ('first', 'second'):
+ (root / f'llvm/{prefix}_dispatch_0.mlir').write_text('%0 = arith.constant 0 : i32 loc("ir_00_torch_input.mlir":3:10)')
+ index = SourceIndex(root)
+ self.assertIsNone(index.file_for('legacy_dispatch_0.mlir'))
+ self.assertEqual(index.resolve_frame({'file': 'helper.cpp', 'line': 1}), [])
+ self.assertIsNone(index.canonical('/unrelated/ir_00_torch_input.mlir:3:10'))
+ self.assertIsNone(index.canonical('ir_00_torch_input.mlir:30:10'))
+ origins, relationship = index.resolve_frames([{'file': 'helper.cpp', 'line': 1}, {'file': 'first_dispatch_0.mlir', 'line': 1}])
+ self.assertEqual(relationship, 'inline_callsite')
+ self.assertEqual(origins[0]['line'], 3)
+
+ def test_anchor_membership_is_added_once(self):
+ from compilerlens.ingest.schema import Stage, Operation
+ from compilerlens.ingest.lineage import build_lineage
+ anchor = Stage('s0', 0, 'torch-input', 'Torch', 'frontend', 'mlir', 'mlir/input', 'matmul')
+ target = Stage('s1', 1, 'llvm-codegen', 'LLVM', 'llvm', 'llvm', 'llvm/file.ll', 'fadd')
+ anchor.ops = [Operation('s0:op1', 's0', 1, 'torch.aten.matmul', 'torch')]
+ target.ops = [Operation('s1:op1', 's1', 1, 'fadd', 'arithmetic')]
+ result = build_lineage([anchor, target], source_sets={'s0:op1': ['anchor:1:1'], 's1:op1': ['anchor:1:1']})
+ self.assertEqual(result['lines']['1']['stages']['s0'], [1])
+
+ def test_native_fallback_is_explicit(self):
+ with tempfile.TemporaryDirectory() as tmp, patch.dict(os.environ, {'COMPILERLENS_NATIVE': '/not-present/native'}):
+ self.assertEqual(analyze_files(Path(tmp))['status'], 'fallback')
+ with self.assertRaises(RuntimeError): analyze_files(Path(tmp), 'required')
+
+
+class QueryTests(unittest.TestCase):
+ def setUp(self):
+ self.artifact = {'artifact_version': '0.7', 'stages': [
+ {'id': 's0', 'name': 'torch-input', 'text': 'a\nb\nc', 'line_count': 3, 'track': 'module', 'language': 'mlir', 'ops': [{'id': 's0:op3', 'name': 'torch.aten.matmul', 'line': 3}], 'op_count': 1},
+ {'id': 's1', 'name': 'target-asm', 'text': ' add\n ret', 'line_count': 2, 'track': 'device', 'language': 'asm', 'ops': [], 'op_count': 2}],
+ 'architecture': {'nodes': [{'id': 'layer', 'path': 'layer', 'source_lines': [3], 'mapping': 'exact'}]}}
+ self.prov = {'status': 'native', 'stages': {'s1': {'records': [
+ {'id': 's1:op1', 'line': 1, 'opcode': 'add', 'origins': [{'file': 'mlir/ir_00_torch_input.mlir', 'line': 3, 'column': 10}, {'file': 'mlir/ir_00_torch_input.mlir', 'line': 2, 'column': 5}]},
+ {'id': 's1:op2', 'line': 2, 'opcode': 'ret', 'origins': []}]}}}
+
+ def test_forward_and_reverse_preserve_sets(self):
+ forward = trace_report(self.artifact, self.prov, source='torch-input:3:10', to='asm')
+ self.assertEqual(len(forward['matches']), 1)
+ self.assertEqual(len(forward['matches'][0]['origins']), 2)
+ reverse = trace_report(self.artifact, self.prov, from_stage='target-asm', line=1)
+ self.assertEqual(len(reverse['origins']), 2)
+ self.assertEqual(trace_report(self.artifact, self.prov, from_stage='target-asm', line=2)['origins'], [])
+
+ def test_invalid_selectors_and_incompatible_tracks(self):
+ with self.assertRaises(ValueError): trace_report(self.artifact, self.prov, module='missing')
+ with self.assertRaises(ValueError): trace_report(self.artifact, self.prov, module='layer', line=1)
+ with self.assertRaises(ValueError): diff_report(self.artifact, self.prov, 'torch-input', 'target-asm')
+ with self.assertRaises(ValueError): inspect_report(self.artifact, self.prov, show='ir', stage='target-asm', lines=(1, 3))
+
+ def test_op_and_module_selectors(self):
+ self.assertEqual(len(trace_report(self.artifact, self.prov, op='s0:op3')['matches']), 1)
+ self.assertEqual(len(trace_report(self.artifact, self.prov, module='layer')['matches']), 1)
+
+ def test_cli_json_and_bad_exit(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ atomic_json(root / 'artifact.json', self.artifact)
+ atomic_json(root / 'lineage.json', {'schema_version': 1, **self.prov})
+ cmd = [sys.executable, '-m', 'compilerlens', 'trace', tmp, '--source', 'torch-input:3:10', '--format', 'json']
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(len(json.loads(result.stdout)['matches']), 1)
+ result = subprocess.run([sys.executable, '-m', 'compilerlens', 'trace', tmp, '--module', 'absent'], capture_output=True, text=True)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertNotIn('Traceback', result.stderr)
+
+ def test_saved_run_sidecar_compatibility(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ atomic_json(root / 'artifact.json', self.artifact)
+ legacy = {'schema_version': 1, **self.prov}
+ atomic_json(root / 'provenance.json', legacy)
+ self.assertEqual(load_run(root)[2], legacy)
+ current = {**legacy, 'status': 'partial'}
+ atomic_json(root / 'lineage.json', current)
+ self.assertEqual(load_run(root)[2], current)
+ atomic_json(root / 'lineage.json', {'schema_version': 999})
+ with self.assertRaisesRegex(ValueError, 'Unsupported lineage schema'):
+ load_run(root)
+
+
+class ServiceTests(unittest.TestCase):
+ def test_input_values_survive_including_infinity(self):
+ import torch
+ import numpy as np
+ with tempfile.TemporaryDirectory() as tmp:
+ tensor = torch.tensor([[0.0, float('-inf')], [0.0, 0.0]])
+ records = save_inputs((tensor,), Path(tmp))
+ np.testing.assert_equal(np.load(Path(tmp) / records[0]['path']), tensor.numpy())
+
+ def test_reject_invalid_compile_before_work(self):
+ with self.assertRaises(ValueError): capture('model', example='matmul')
+ with self.assertRaises(ValueError): capture(example='matmul', seq_len=32)
+ with tempfile.TemporaryDirectory() as tmp:
+ with self.assertRaises(FileExistsError): capture(example='matmul', out=tmp)
+
+ def test_failed_run_records_error(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ out = Path(tmp) / 'failed'
+ with self.assertRaises(ValueError): capture(example='unknown', out=out)
+ self.assertEqual(json.loads((out / 'run.json').read_text())['status'], 'failed')
+
+ def test_import_does_not_overwrite_source(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp); (root / 'mlir').mkdir()
+ with self.assertRaises(ValueError): import_dump(root, out=root / 'nested')
+
+ def test_offline_revision_resolves_cached_ref_without_api(self):
+ from compilerlens.models.detect import _resolve_revision
+ sha = 'a' * 40
+ with patch('compilerlens.models.detect.HfApi') as api, patch('compilerlens.models.detect.hf_hub_download', return_value=f'/cache/snapshots/{sha}/config.json') as download:
+ self.assertEqual(_resolve_revision('example/model', 'release', offline=True), sha)
+ api.assert_not_called()
+ self.assertTrue(download.call_args.kwargs['local_files_only'])
+
+
+if __name__ == '__main__': unittest.main()
diff --git a/tests/test_explore_lineage.py b/tests/test_explore_lineage.py
new file mode 100644
index 0000000..e3ee582
--- /dev/null
+++ b/tests/test_explore_lineage.py
@@ -0,0 +1,79 @@
+"""Web Explore forwards lineage modes and reports capture progress safely."""
+import json
+import os
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from fastapi.testclient import TestClient
+
+from compilerlens.backend.api import app as api
+from compilerlens.storage import atomic_json
+
+
+class ExploreLineageTests(unittest.TestCase):
+ def test_modes_and_default_reach_capture(self):
+ for mode in (None, 'auto', 'required', 'off'):
+ with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ run = root / 'captured'
+ atomic_json(run / 'model_info.json', {'model_id': 'test/model'})
+ atomic_json(run / 'artifact.json', {'compilation_id': 'test-run'})
+ atomic_json(run / 'index.json', {'workloads': [{'id': 'test-run'}]})
+ progress_updates = []
+
+ def capture_with_progress(*args, progress, **kwargs):
+ job = next(iter(api.JOBS.values()))
+ self.assertIsNone(job['progress'])
+ for label in ('Loading workload', 'Compiling workload'):
+ progress(label)
+ progress_updates.append(dict(job['progress']))
+ return run
+
+ with patch.object(api, 'REPO_ROOT', root), patch.object(api, 'JOBS', {}), \
+ patch('compilerlens.services.capture', side_effect=capture_with_progress) as capture, \
+ TestClient(api.app) as client:
+ body = {'model_id': 'test/model'}
+ if mode is not None:
+ body['lineage'] = mode
+ body.update(revision='a' * 40, offline=True)
+ response = client.post('/explore', json=body)
+ self.assertEqual(response.status_code, 200, response.text)
+ job_id = response.json()['job_id']
+ job = client.get(f'/compile/{job_id}').json()
+ expected = mode or 'auto'
+ self.assertEqual(capture.call_args.kwargs['lineage'], expected)
+ self.assertEqual(capture.call_args.kwargs['revision'], body.get('revision'))
+ self.assertEqual(capture.call_args.kwargs['offline'], body.get('offline', False))
+ self.assertEqual(job['lineage'], expected)
+ self.assertEqual(job['status'], 'done')
+ self.assertEqual(progress_updates, [
+ {'label': 'Loading workload', 'done': 1, 'total': 24},
+ {'label': 'Compiling workload', 'done': 2, 'total': 24},
+ ])
+ self.assertEqual(job['progress'], {'label': 'Complete', 'done': 1, 'total': 1})
+ saved = json.loads((root / 'jobs' / f'{job_id}.json').read_text())
+ self.assertEqual(saved['lineage'], expected)
+ self.assertEqual(saved['progress'], job['progress'])
+
+ def test_invalid_mode_is_rejected_before_capture(self):
+ with patch('compilerlens.services.capture') as capture, TestClient(api.app) as client:
+ response = client.post('/explore', json={'model_id': 'test/model', 'lineage': 'invalid'})
+ self.assertEqual(response.status_code, 422)
+ capture.assert_not_called()
+
+ def test_required_missing_analyzer_gives_actionable_error(self):
+ with tempfile.TemporaryDirectory() as directory, \
+ patch.object(api, 'REPO_ROOT', Path(directory)), patch.object(api, 'JOBS', {}), \
+ patch.dict(os.environ, {'COMPILERLENS_NATIVE': '/not-present/native'}), \
+ self.assertLogs(api.LOGGER, level='ERROR'), TestClient(api.app) as client:
+ response = client.post('/explore', json={'model_id': 'test/model', 'lineage': 'required'})
+ job = client.get(f'/compile/{response.json()["job_id"]}').json()
+ self.assertEqual(job['status'], 'failed')
+ self.assertIn('Native lineage analysis', job['error'])
+ self.assertIn('Automatic or Off', job['error'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_llvm_lineage.py b/tests/test_llvm_lineage.py
index 0b9a97d..56c38fd 100644
--- a/tests/test_llvm_lineage.py
+++ b/tests/test_llvm_lineage.py
@@ -2,8 +2,8 @@
import unittest
-from ingest.llvm_parser import parse_llvm_ir
-from ingest.mlir_loc import source_locations_by_line
+from compilerlens.ingest.llvm_parser import parse_llvm_ir
+from compilerlens.ingest.mlir_loc import source_locations_by_line
class MlirLineSourceMapTests(unittest.TestCase):
diff --git a/tests/test_model_adapters.py b/tests/test_model_adapters.py
new file mode 100644
index 0000000..2b70fd4
--- /dev/null
+++ b/tests/test_model_adapters.py
@@ -0,0 +1,293 @@
+"""Offline model-family and numerical compiler checks; no Hub downloads."""
+import tempfile
+import json
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import numpy as np
+import torch
+from transformers import CLIPConfig, CLIPModel, ViTConfig, ViTModel
+
+from compilerlens.models.adapters import adapter_for, clip_inputs
+from compilerlens.models.detect import detect, normalize_model_id, UnsupportedModelError
+from compilerlens.models.hf_wrapper import wrap
+
+
+def tiny_model(family):
+ 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)
+ if family == 'vit':
+ config = ViTConfig(**vision)
+ model_class = ViTModel
+ else:
+ 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)
+ config = CLIPConfig(text_config=text, vision_config=vision, projection_dim=4)
+ model_class = CLIPModel
+ config._attn_implementation = 'eager'
+ with torch.random.fork_rng():
+ torch.manual_seed(0)
+ model = model_class(config, **({'add_pooling_layer': False} if family == 'vit' else {})).eval()
+ return config, model
+
+
+def detect_config(config, seq_len=4):
+ with patch('compilerlens.models.detect._resolve_revision', return_value='a' * 40), \
+ patch('compilerlens.models.detect._raw_config', return_value=config.to_dict()), \
+ patch('transformers.AutoConfig.from_pretrained', return_value=config):
+ return detect('test/model', seq_len=seq_len, offline=True)
+
+
+class ModelAdapterTests(unittest.TestCase):
+ def test_hub_urls_normalize_consistently(self):
+ from compilerlens.backend.api.app import _normalize_model_id
+ for value, expected in (
+ (' org/model ', 'org/model'),
+ ('https://huggingface.co/org/model/', 'org/model'),
+ ('https://huggingface.co/org/model/tree/main', 'org/model'),
+ ('https://huggingface.co/gpt2', 'gpt2'),
+ ):
+ self.assertEqual(normalize_model_id(value), expected)
+ self.assertEqual(_normalize_model_id(value), expected)
+
+ def test_vit_classification_checkpoint_loads_without_random_pooler(self):
+ from transformers import ViTForImageClassification
+ config, _ = tiny_model('vit')
+ classifier = ViTForImageClassification(config).eval()
+ detected = detect_config(config)
+ with tempfile.TemporaryDirectory() as directory:
+ classifier.save_pretrained(directory)
+ detected.model_id = directory
+ loaded = detected.load_model(dtype=torch.float32)
+ self.assertIsNone(loaded.pooler)
+ self.assertEqual(sum(p.numel() for p in loaded.parameters()),
+ sum(p.numel() for p in classifier.vit.parameters()))
+ image = torch.randn(1, 3, 8, 8)
+ with torch.no_grad():
+ torch.testing.assert_close(loaded(image).last_hidden_state,
+ classifier.vit(image).last_hidden_state)
+
+ def test_existing_text_models_keep_their_adapter(self):
+ from transformers import BertConfig, BertModel, GPT2Config, GPT2LMHeadModel
+ for config, model_class in (
+ (BertConfig(vocab_size=16, hidden_size=8, intermediate_size=16,
+ num_hidden_layers=1, num_attention_heads=2), BertModel),
+ (GPT2Config(vocab_size=16, n_embd=8, n_layer=1, n_head=2, n_positions=16,
+ bos_token_id=1, eos_token_id=2), GPT2LMHeadModel),
+ ):
+ detected = detect_config(config)
+ self.assertEqual(detected.adapter, 'text')
+ with patch.object(detected, 'load_model', return_value=model_class(config).eval()):
+ module, inputs, info = wrap(detected)
+ self.assertEqual(info['modalities'], ['text'])
+ with torch.no_grad():
+ self.assertEqual(module(*inputs).shape[:2], (1, 4))
+
+ def test_clip_legacy_pooling_and_seed_reproducibility(self):
+ profile = dict(image_size=[8, 8], num_channels=3, vocab_size=16,
+ bos_token_id=14, eos_token_id=2)
+ with torch.random.fork_rng():
+ torch.manual_seed(19)
+ first = clip_inputs(profile, 4)
+ torch.manual_seed(19)
+ second = clip_inputs(profile, 4)
+ for a, b in zip(first, second):
+ torch.testing.assert_close(a, b)
+ self.assertEqual(first[0].argmax().item(), 3)
+
+ def test_profiles_and_outputs(self):
+ for family in ('vit', 'clip'):
+ with self.subTest(family=family):
+ config, model = tiny_model(family)
+ detected = detect_config(config)
+ with patch.object(detected, 'load_model', return_value=model):
+ module, inputs, info = wrap(detected)
+ self.assertEqual(detected.adapter, family)
+ self.assertEqual(info['input_profile']['kind'], 'synthetic')
+ self.assertEqual(len(info['input_profile']['inputs']), len(inputs))
+ self.assertEqual(info['modalities'], ['image'] if family == 'vit' else ['image', 'text'])
+ with torch.no_grad():
+ outputs = module(*inputs)
+ self.assertEqual(len(info['output_names']), len(outputs) if isinstance(outputs, tuple) else 1)
+ if family == 'clip':
+ self.assertEqual(inputs[0][0, -1].item(), config.text_config.eos_token_id)
+ self.assertFalse((inputs[0][0, :-1] == config.text_config.eos_token_id).any())
+
+ def test_invalid_vision_profiles_fail_before_loading(self):
+ config, _ = tiny_model('vit')
+ config.image_size = 4096
+ with self.assertRaisesRegex(UnsupportedModelError, '512'):
+ detect_config(config)
+ config, _ = tiny_model('clip')
+ for length in (1, 9):
+ with self.assertRaisesRegex(UnsupportedModelError, 'sequence length'):
+ detect_config(config, seq_len=length)
+
+ def test_parameter_guard_precedes_weight_download(self):
+ for family in ('vit', 'clip'):
+ with self.subTest(family=family):
+ config, model = tiny_model(family)
+ detected = detect_config(config)
+ count = sum(p.numel() for p in model.parameters())
+ with patch.object(detected, 'load_model', return_value=model) as load:
+ with self.assertRaisesRegex(ValueError, 'before weight download'):
+ wrap(detected, max_parameters=count - 1)
+ load.assert_not_called()
+ wrap(detected, max_parameters=count)
+ load.assert_called_once()
+
+ def test_loading_uses_correct_class_and_pinned_offline_revision(self):
+ for family, class_name in (('vit', 'ViTModel'), ('clip', 'CLIPModel')):
+ config, model = tiny_model(family)
+ detected = detect_config(config)
+ with patch(f'transformers.{class_name}.from_pretrained', return_value=model) as load:
+ detected.load_model(dtype=torch.float32)
+ self.assertEqual(load.call_args.kwargs['revision'], 'a' * 40)
+ self.assertTrue(load.call_args.kwargs['local_files_only'])
+ self.assertFalse(load.call_args.kwargs['trust_remote_code'])
+ self.assertEqual(load.call_args.kwargs['attn_implementation'], 'eager')
+ if family == 'vit':
+ self.assertFalse(load.call_args.kwargs['add_pooling_layer'])
+
+ def test_native_hf_forward_export_and_compiled_outputs_agree(self):
+ import iree.compiler as compiler
+ import iree.runtime as runtime
+ import iree.turbine.aot as aot
+ from compilerlens.models.architecture import export_program, capture_architecture
+
+ for family in ('vit', 'clip'):
+ with self.subTest(family=family), torch.random.fork_rng():
+ torch.manual_seed(7)
+ config, model = tiny_model(family)
+ detected = detect_config(config)
+ adapter = adapter_for(detected)
+ module, inputs = adapter.prepare(detected, model)
+ with torch.no_grad():
+ expected = module(*inputs)
+ if family == 'clip':
+ # Independent HF path: normal 2D mask, not our export mask.
+ native = model(input_ids=inputs[0], pixel_values=inputs[1],
+ attention_mask=torch.ones_like(inputs[0]))
+ native = (native.image_embeds, native.text_embeds, native.logits_per_image)
+ else:
+ native = (model(pixel_values=inputs[0]).last_hidden_state,)
+ expected = expected if isinstance(expected, tuple) else (expected,)
+ for actual, reference in zip(expected, native):
+ torch.testing.assert_close(actual, reference)
+ program = export_program(module, inputs)
+ mlir = str(aot.export(program).mlir_module)
+ architecture = capture_architecture(module, program, mlir,
+ {'model_id': 'test/model', **adapter.metadata(detected, inputs)})
+ self.assertEqual(architecture['mapping_status'], 'exact')
+ binary = compiler.compile_str(mlir, target_backends=['llvm-cpu'],
+ extra_args=['--iree-llvmcpu-target-cpu=generic'])
+ vm = runtime.load_vm_flatbuffer(binary, driver='local-sync')
+ actual = vm.main(*(tensor.numpy() for tensor in inputs))
+ actual = actual if isinstance(actual, (list, tuple)) else (actual,)
+ self.assertEqual(len(actual), len(expected))
+ for result, reference in zip(actual, expected):
+ np.testing.assert_allclose(result.to_host(), reference.numpy(), rtol=1e-4, atol=1e-4)
+
+ def test_cli_demo_capture_has_viewable_artifact_and_verification(self):
+ from compilerlens.storage import load_run
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory) / 'vit'
+ result = subprocess.run([sys.executable, '-m', 'compilerlens', 'compile',
+ '--example', 'tiny_vit', '--out', str(root), '--format', 'json'],
+ capture_output=True, text=True, timeout=120)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(json.loads(result.stdout)['status'], 'complete')
+ _, artifact, _ = load_run(root)
+ self.assertIn('asm', {stage['language'] for stage in artifact['stages']})
+ facts = artifact['architecture']['model']
+ self.assertEqual(facts['modalities'], ['image'])
+ self.assertEqual(facts['weights'], 'random')
+ self.assertEqual(facts['input_profile']['inputs'][0]['shape'], [1, 3, 8, 8])
+ self.assertEqual(json.loads((root / 'verification.json').read_text())['status'], 'passed')
+
+ def test_web_explore_clip_uses_shared_adapter_and_publishes_artifact(self):
+ from fastapi.testclient import TestClient
+ from compilerlens.backend.api import app as api
+ config, model = tiny_model('clip')
+ detected = detect_config(config)
+ with tempfile.TemporaryDirectory() as directory, \
+ patch.object(api, 'REPO_ROOT', Path(directory)), patch.object(api, 'JOBS', {}), \
+ patch('compilerlens.models.detect.detect', return_value=detected), \
+ patch.object(detected, 'load_model', return_value=model), TestClient(api.app) as client:
+ response = client.post('/explore', json={'model_id': 'test/model', 'seq_len': 4})
+ self.assertEqual(response.status_code, 200)
+ job_id = response.json()['job_id']
+ job = client.get(f'/compile/{job_id}').json()
+ self.assertEqual(job['status'], 'done', job['error'])
+ artifact = json.loads((Path(directory) / 'frontend/public/artifacts' /
+ f'{job["artifact_id"]}.json').read_text())
+ self.assertEqual(artifact['architecture']['model']['modalities'], ['image', 'text'])
+ self.assertEqual(artifact['architecture']['mapping_status'], 'exact')
+ self.assertTrue(any(stage['language'] == 'asm' for stage in artifact['stages']))
+ report = json.loads((Path(directory) / 'runs' / job_id / 'verification.json').read_text())
+ self.assertEqual(report['status'], 'passed')
+ self.assertEqual(len(report['outputs']), 3)
+
+ def test_verification_failure_is_recorded(self):
+ from compilerlens.models.verification import verify_forward
+ from types import SimpleNamespace
+ bad_output = SimpleNamespace(to_host=lambda: np.array([99.0], dtype=np.float32))
+ vm = SimpleNamespace(main=lambda *args: bad_output)
+ with tempfile.TemporaryDirectory() as directory, \
+ patch('iree.runtime.load_vm_flatbuffer', return_value=vm):
+ root = Path(directory)
+ (root / 'module.vmfb').touch()
+ with self.assertRaisesRegex(RuntimeError, 'verification failed'):
+ verify_forward(root / 'module.vmfb', torch.nn.Identity(),
+ (torch.ones(1),), ['value'], root / 'verification.json')
+ self.assertEqual(json.loads((root / 'verification.json').read_text())['status'], 'failed')
+
+ def test_float32_tolerance_keeps_absolute_floor_and_rejects_nonfinite(self):
+ from compilerlens.models.verification import verify_forward
+ from types import SimpleNamespace
+ for expected, actual, passes in ((1., 1.0008, True), (0., 0.0002, False),
+ (1., float('nan'), False)):
+ with self.subTest(actual=actual), tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / 'module.vmfb').touch()
+ result = SimpleNamespace(to_host=lambda: np.array([actual], dtype=np.float32))
+ vm = SimpleNamespace(main=lambda *args: result)
+ with patch('iree.runtime.load_vm_flatbuffer', return_value=vm):
+ if passes:
+ report = verify_forward(root / 'module.vmfb', torch.nn.Identity(),
+ (torch.tensor([expected]),), ['value'], root / 'verification.json')
+ self.assertEqual(report['status'], 'passed')
+ else:
+ with self.assertRaises(RuntimeError):
+ verify_forward(root / 'module.vmfb', torch.nn.Identity(),
+ (torch.tensor([expected]),), ['value'], root / 'verification.json')
+
+ def test_playground_clip_uses_adapter_and_verifies_outputs(self):
+ from fastapi.testclient import TestClient
+ from compilerlens.backend.api import app as api
+ config, model = tiny_model('clip')
+ detected = detect_config(config)
+ with tempfile.TemporaryDirectory() as directory, \
+ patch.object(api, 'REPO_ROOT', Path(directory)), patch.object(api, 'JOBS', {}), \
+ patch('compilerlens.models.detect.detect', return_value=detected), \
+ patch.object(detected, 'load_model', return_value=model), TestClient(api.app) as client:
+ response = client.post('/compile', json={'model_id': 'test/model', 'seq_len': 4,
+ 'stages': ['input'], 'options': {'target-cpu': 'generic'},
+ 'revision': 'a' * 40, 'offline': True})
+ self.assertEqual(response.status_code, 200)
+ job_id = response.json()['job_id']
+ job = client.get(f'/compile/{job_id}').json()
+ self.assertEqual(job['status'], 'done', job['error'])
+ self.assertEqual(job['model_info']['adapter'], 'clip')
+ self.assertEqual(job['stages_available'], ['input'])
+ report = json.loads((Path(directory) / 'jobs' / job_id / 'verification.json').read_text())
+ self.assertEqual(report['status'], 'passed')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py
new file mode 100644
index 0000000..e7deb48
--- /dev/null
+++ b/tests/test_release_workflow.py
@@ -0,0 +1,54 @@
+"""Regression checks for release-tool selection and tag-only publication."""
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import yaml
+
+
+WORKFLOW = Path(__file__).resolve().parents[1] / '.github/workflows/publish.yml'
+
+
+class ReleaseWorkflowTests(unittest.TestCase):
+ def setUp(self):
+ # BaseLoader preserves the YAML key "on" instead of interpreting it as bool.
+ self.workflow = yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader)
+ step = next(s for s in self.workflow['jobs']['build']['steps']
+ if s.get('name') == 'Verify wheel repair tools')
+ self.preflight = step['run'].split("python - <<'PY'\n", 1)[1].rsplit('\nPY', 1)[0]
+
+ def run_preflight(self, version='0.19.1.0', executable='/release-venv/bin/patchelf'):
+ versions = {'auditwheel': '6.8.2', 'patchelf': version}
+ with patch('importlib.metadata.version', side_effect=versions.__getitem__), \
+ patch('shutil.which', return_value=executable), \
+ patch('sysconfig.get_path', return_value='/release-venv/bin'), \
+ patch('subprocess.run') as run:
+ exec(compile(self.preflight, str(WORKFLOW), 'exec'), {})
+ return run
+
+ def test_pinned_tools_pass_preflight(self):
+ run = self.run_preflight()
+ self.assertEqual(run.call_count, 2)
+ run.assert_any_call(['/release-venv/bin/patchelf', '--version'], check=True)
+
+ def test_old_patchelf_package_is_rejected(self):
+ with self.assertRaisesRegex(SystemExit, 'expected 0.19.1.0'):
+ self.run_preflight(version='0.14.3')
+
+ def test_shadowing_system_patchelf_is_rejected(self):
+ with self.assertRaisesRegex(SystemExit, 'Wrong patchelf on PATH'):
+ self.run_preflight(executable='/usr/bin/patchelf')
+
+ def test_branch_builds_cannot_publish(self):
+ trigger = self.workflow['on']
+ self.assertIn('main', trigger['push']['branches'])
+ self.assertIn('feature/experiments-cli', trigger['push']['branches'])
+ self.assertIn('pull_request', trigger)
+ self.assertEqual(trigger['push']['tags'], ['v*'])
+ self.assertEqual(self.workflow['jobs']['publish']['if'],
+ "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')")
+ self.assertEqual(self.workflow['jobs']['publish']['needs'], 'build')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_source_artifacts.py b/tests/test_source_artifacts.py
new file mode 100644
index 0000000..54cb138
--- /dev/null
+++ b/tests/test_source_artifacts.py
@@ -0,0 +1,102 @@
+"""Source artifact generation must share the capture pipeline's lineage behavior."""
+import contextlib
+import io
+import json
+import os
+from pathlib import Path
+import shutil
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from compilerlens.ingest import build
+from compilerlens.ingest.workloads.generated import spec_from_dump_dir
+from compilerlens.lineage import analyze_files, native_binary
+
+
+class SourceArtifactTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ self.root = Path(self.directory.name) / 'fixture'
+ (self.root / 'mlir').mkdir(parents=True)
+ (self.root / 'llvm').mkdir()
+ (self.root / 'mlir/ir_00_torch_input.mlir').write_text(
+ '%0 = torch.aten.relu %arg0 : !torch.vtensor<[4],f32>\n')
+ (self.root / 'llvm/dispatch_0.mlir').write_text(
+ '%0 = arith.constant 0 : i32 loc("ir_00_torch_input.mlir":1:6)\n' * 20)
+ fixture = Path(__file__).resolve().parents[1] / 'llvm/tests/fixture.ll'
+ shutil.copyfile(fixture, self.root / 'llvm/dispatch_0.optimized.ll')
+ (self.root / 'llvm/dispatch_0.s').write_text(
+ '.file 1 "/captured" "dispatch_0.mlir"\n'
+ '.loc 1 18 8\n movl (%rdi), %eax\n retq\n')
+ self.workload = spec_from_dump_dir(self.root, {'model_id': 'fixture'})
+
+ def test_default_auto_records_fallback_without_native_binary(self):
+ with patch.dict(os.environ, {'COMPILERLENS_NATIVE': '/not-present/native'}):
+ artifact = build.build_artifact(self.workload)
+ sidecar = json.loads((self.root / 'lineage.json').read_text())
+ self.assertEqual(sidecar['status'], 'fallback')
+ self.assertTrue(any(note.startswith('Lineage: fallback') for note in artifact.notes))
+ assembly = next(s for s in artifact.stages if s.language == 'asm')
+ self.assertEqual(artifact.lineage['lines']['1']['stages'][assembly.id], [3, 4])
+
+ def test_required_rejects_missing_native_for_single_and_all_builds(self):
+ for mode in ('single', 'all'):
+ with self.subTest(mode=mode):
+ output = Path(self.directory.name) / mode
+ args = (['--all', '--out-dir', str(output)] if mode == 'all' else
+ [str(self.root), '--id', 'fixture', '--out', str(output)])
+ with patch.object(build, 'WORKLOADS', {'fixture': self.workload}), \
+ patch('sys.argv', ['ingest.build', *args, '--lineage', 'required']), \
+ patch.dict(os.environ, {'COMPILERLENS_NATIVE': '/not-present/native'}), \
+ contextlib.redirect_stderr(io.StringIO()) as errors:
+ with self.assertRaises(SystemExit) as failure:
+ build.main()
+ self.assertEqual(failure.exception.code, 1)
+ self.assertIn('Native analyzer unavailable', errors.getvalue())
+ self.assertFalse((output / 'index.json').exists() if mode == 'all' else output.exists())
+
+ def test_off_skips_native_execution(self):
+ with patch('compilerlens.lineage.native_binary', side_effect=AssertionError('must not run')):
+ build.build_artifact(self.workload, lineage='off')
+ self.assertEqual(json.loads((self.root / 'lineage.json').read_text())['status'], 'off')
+
+ def test_capture_analysis_is_reused(self):
+ analysis = analyze_files(self.root, 'off')
+ with patch('compilerlens.lineage.analyze_files', side_effect=AssertionError('must not rerun')):
+ build.build_artifact(self.workload, analysis=analysis)
+
+ @unittest.skipUnless(native_binary(), 'Build the native analyzer for source integration checks')
+ def test_native_results_reach_single_and_all_frontend_artifacts(self):
+ original = (self.root / 'llvm/dispatch_0.optimized.ll').read_bytes()
+ for mode in ('single', 'all'):
+ with self.subTest(mode=mode):
+ output = Path(self.directory.name) / mode
+ args = (['--all', '--out-dir', str(output)] if mode == 'all' else
+ [str(self.root), '--id', 'fixture', '--out', str(output)])
+ with patch.object(build, 'WORKLOADS', {'fixture': self.workload}), \
+ patch('sys.argv', ['ingest.build', *args, '--lineage', 'required']), \
+ contextlib.redirect_stdout(io.StringIO()), \
+ contextlib.redirect_stderr(io.StringIO()):
+ build.main()
+ artifact_path = output / 'fixture.json' if mode == 'all' else output
+ artifact = json.loads(artifact_path.read_text())
+ sidecar = json.loads((self.root / 'lineage.json').read_text())
+ self.assertEqual(sidecar['status'], 'native')
+ for language in ('llvm', 'asm'):
+ stage = next(s for s in artifact['stages'] if s['language'] == language)
+ mapped = artifact['lineage']['lines']['1']['stages'][stage['id']]
+ self.assertTrue(mapped)
+ self.assertTrue(all(1 <= line <= stage['line_count'] for line in mapped))
+ if language == 'llvm':
+ self.assertIn('; compilerlens.id=', stage['text'])
+ self.assertTrue(sidecar['stages'][stage['id']]['native'])
+ if mode == 'all':
+ index = json.loads((output / 'index.json').read_text())
+ self.assertEqual(index['workloads'][0]['id'], 'fixture')
+ self.assertEqual((self.root / 'llvm/dispatch_0.optimized.ll').read_bytes(), original)
+
+
+if __name__ == '__main__':
+ unittest.main()