From 42c45f4e19c04533dc012e068201be573d439356 Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Fri, 25 Sep 2026 01:35:20 +0530 Subject: [PATCH 01/14] Add installable CLI and native LLVM provenance --- .gitignore | 10 + FINALS_5_DAY_PLAN.md | 713 ++++++++++++++++++ MANIFEST.in | 9 + README.md | 20 + backend/__init__.py | 3 + compilerlens/__init__.py | 13 + compilerlens/__main__.py | 3 + .../api => compilerlens/backend}/__init__.py | 0 .../backend/api}/__init__.py | 0 {backend => compilerlens/backend}/api/app.py | 134 ++-- .../backend}/api/run_server.py | 2 +- .../backend/compiler}/__init__.py | 0 .../backend}/compiler/runner.py | 19 +- compilerlens/backend/measure/__init__.py | 0 .../backend}/measure/bench.py | 7 +- .../backend}/measure/cli.py | 12 +- .../backend}/measure/compare.py | 30 +- .../backend}/measure/variants.py | 0 compilerlens/benchmark.py | 24 + compilerlens/cli.py | 211 ++++++ compilerlens/examples/__init__.py | 1 + compilerlens/examples/linear_relu.py | 19 + compilerlens/examples/matmul.py | 14 + compilerlens/examples/mini_transformer.py | 30 + compilerlens/ingest/__init__.py | 0 .../ingest}/architecture.py | 0 {ingest => compilerlens/ingest}/build.py | 24 +- {ingest => compilerlens/ingest}/lineage.py | 24 +- .../ingest}/llvm_parser.py | 0 {ingest => compilerlens/ingest}/mlir_loc.py | 25 + .../ingest}/mlir_parser.py | 0 {ingest => compilerlens/ingest}/pass_log.py | 0 {ingest => compilerlens/ingest}/schema.py | 0 .../ingest}/workloads/__init__.py | 3 +- .../ingest}/workloads/base.py | 0 .../ingest}/workloads/generated.py | 54 +- .../ingest}/workloads/linear_relu.py | 0 .../ingest}/workloads/matmul.py | 0 compilerlens/models/__init__.py | 0 .../models}/architecture.py | 6 +- {models => compilerlens/models}/detect.py | 52 +- {models => compilerlens/models}/hf_wrapper.py | 2 +- {models => compilerlens/models}/prefetch.py | 2 +- compilerlens/provenance.py | 292 +++++++ compilerlens/queries.py | 129 ++++ compilerlens/server.py | 58 ++ compilerlens/services.py | 185 +++++ compilerlens/storage.py | 56 ++ compilerlens/toolchain.py | 9 + docs/CLI.md | 187 +++++ docs/RELEASING.md | 368 +++++++++ frontend/vite.release.config.ts | 10 + ingest/__init__.py | 3 + models/__init__.py | 3 + native/CMakeLists.txt | 40 + native/LLVM-LICENSE.txt | 278 +++++++ native/include/compilerlens/Checkpoint.h | 35 + native/include/compilerlens/Provenance.h | 15 + native/iree/.gitattributes | 2 + native/iree/CompilerLens.cmake | 10 + native/iree/LLVMIRPasses.patch | 28 + native/iree/README.md | 27 + native/lib/Plugin.cpp | 13 + native/lib/Provenance.cpp | 107 +++ native/tests/check_reports.py | 47 ++ native/tests/checkpoint.cpp | 21 + native/tests/fixture.ll | 22 + native/tools/main.cpp | 112 +++ pyproject.toml | 27 + scripts/build_release.py | 57 ++ scripts/check_installed.py | 54 ++ scripts/check_viewer.mjs | 20 + scripts/compile_hf_model.py | 6 +- setup.py | 27 + tests/test_architecture.py | 6 +- tests/test_cli_provenance.py | 142 ++++ tests/test_llvm_lineage.py | 4 +- 77 files changed, 3654 insertions(+), 212 deletions(-) create mode 100644 FINALS_5_DAY_PLAN.md create mode 100644 MANIFEST.in create mode 100644 compilerlens/__init__.py create mode 100644 compilerlens/__main__.py rename {backend/api => compilerlens/backend}/__init__.py (100%) rename {backend/compiler => compilerlens/backend/api}/__init__.py (100%) rename {backend => compilerlens/backend}/api/app.py (79%) rename {backend => compilerlens/backend}/api/run_server.py (96%) rename {backend/measure => compilerlens/backend/compiler}/__init__.py (100%) rename {backend => compilerlens/backend}/compiler/runner.py (97%) create mode 100644 compilerlens/backend/measure/__init__.py rename {backend => compilerlens/backend}/measure/bench.py (96%) rename {backend => compilerlens/backend}/measure/cli.py (92%) rename {backend => compilerlens/backend}/measure/compare.py (92%) rename {backend => compilerlens/backend}/measure/variants.py (100%) create mode 100644 compilerlens/benchmark.py create mode 100644 compilerlens/cli.py create mode 100644 compilerlens/examples/__init__.py create mode 100644 compilerlens/examples/linear_relu.py create mode 100644 compilerlens/examples/matmul.py create mode 100644 compilerlens/examples/mini_transformer.py create mode 100644 compilerlens/ingest/__init__.py rename {ingest => compilerlens/ingest}/architecture.py (100%) rename {ingest => compilerlens/ingest}/build.py (95%) rename {ingest => compilerlens/ingest}/lineage.py (95%) rename {ingest => compilerlens/ingest}/llvm_parser.py (100%) rename {ingest => compilerlens/ingest}/mlir_loc.py (85%) rename {ingest => compilerlens/ingest}/mlir_parser.py (100%) rename {ingest => compilerlens/ingest}/pass_log.py (100%) rename {ingest => compilerlens/ingest}/schema.py (100%) rename {ingest => compilerlens/ingest}/workloads/__init__.py (93%) rename {ingest => compilerlens/ingest}/workloads/base.py (100%) rename {ingest => compilerlens/ingest}/workloads/generated.py (85%) rename {ingest => compilerlens/ingest}/workloads/linear_relu.py (100%) rename {ingest => compilerlens/ingest}/workloads/matmul.py (100%) create mode 100644 compilerlens/models/__init__.py rename {models => compilerlens/models}/architecture.py (98%) rename {models => compilerlens/models}/detect.py (86%) rename {models => compilerlens/models}/hf_wrapper.py (99%) rename {models => compilerlens/models}/prefetch.py (94%) create mode 100644 compilerlens/provenance.py create mode 100644 compilerlens/queries.py create mode 100644 compilerlens/server.py create mode 100644 compilerlens/services.py create mode 100644 compilerlens/storage.py create mode 100644 compilerlens/toolchain.py create mode 100644 docs/CLI.md create mode 100644 docs/RELEASING.md create mode 100644 frontend/vite.release.config.ts create mode 100644 native/CMakeLists.txt create mode 100644 native/LLVM-LICENSE.txt create mode 100644 native/include/compilerlens/Checkpoint.h create mode 100644 native/include/compilerlens/Provenance.h create mode 100644 native/iree/.gitattributes create mode 100644 native/iree/CompilerLens.cmake create mode 100644 native/iree/LLVMIRPasses.patch create mode 100644 native/iree/README.md create mode 100644 native/lib/Plugin.cpp create mode 100644 native/lib/Provenance.cpp create mode 100644 native/tests/check_reports.py create mode 100644 native/tests/checkpoint.cpp create mode 100644 native/tests/fixture.ll create mode 100644 native/tools/main.cpp create mode 100644 pyproject.toml create mode 100644 scripts/build_release.py create mode 100644 scripts/check_installed.py create mode 100644 scripts/check_viewer.mjs create mode 100644 setup.py create mode 100644 tests/test_cli_provenance.py diff --git a/.gitignore b/.gitignore index e2b9eac..9881625 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build output node_modules/ dist/ +wheelhouse/ *.tsbuildinfo # Generated artifacts -- rebuild with `npm run artifact` (see README) @@ -24,3 +25,12 @@ __pycache__/ .DS_Store .idea/ .vscode/ + +# CompilerLens release products and local runs +build/ +*.egg-info/ +compilerlens/_bin/ +compilerlens/_web/ +compilerlens-runs/ +runs/ +jobs/ diff --git a/FINALS_5_DAY_PLAN.md b/FINALS_5_DAY_PLAN.md new file mode 100644 index 0000000..b5a1f56 --- /dev/null +++ b/FINALS_5_DAY_PLAN.md @@ -0,0 +1,713 @@ +# CompilerLens finals: a complete CLI and native LLVM lineage + +**Updated:** 25 September 2026 + +**Status:** core implementation delivered on `feature/experiments-cli`; see the implementation review below. The original design follows for context. + +**Scope:** keep the existing webpage; expose its capabilities through a clear CLI; add a C++ LLVM pass and extend provenance into assembly. + +**Planning assumption:** two contributors, following the existing compiler/backend and product/frontend ownership split, over five days. + +## Implementation review — 25 September 2026 + +The CLI, native LLVM integration and existing-viewer packaging are implemented. The current +command reference is [docs/CLI.md](docs/CLI.md). The Python implementation now lives under +`compilerlens/`, with checkout compatibility imports for the older commands. + +| Deliverable | Implementation | +|---|---| +| Pip package and console entrypoint | `pyproject.toml`, `setup.py`, `compilerlens/cli.py` | +| Shared capture/import service | `compilerlens/services.py`; used by web Explore and CLI | +| Persistent capture, actual inputs, resolved revisions | `run.json`, `manifest.json`, `inputs/*.npy`, content hashes; existing output directories refused | +| Inspect architecture/stages/evidence/operations/IR | Rich terminal output and JSON; uses the existing artifact schema | +| C++ New Pass Manager pass | `native/lib/Provenance.cpp`, `Plugin.cpp`, CMake; read-only LLVM traversal | +| Native integration | Private executable analyzes every captured LLVM checkpoint; explicit fallback/required/off modes | +| Complete kernel capture | All emitted dispatch kernels and LLVM modules represented, with separate comparison tracks | +| Forward/reverse tracing | `compilerlens/queries.py`, `provenance.py`; explicit origin sets, inline frames and unknown locations | +| Provenance audit | Debug, dispatch, source and exact-module counts; per-function statistics and gap reasons | +| Assembly and object addresses | `.file`/`.loc` state machine plus native LLVM object/DWARF lookup; `inspect --show objects` lists usable sections | +| Existing viewer | Bundled unchanged React app/Monaco workers; loopback server and shared API routes | +| Exact-input benchmark | NPY inputs preserve decoder causal masks; hash checks and timing reliability metadata | +| Release build | `scripts/build_release.py`; native CTest, TypeScript checking, web bundle and platform wheel | +| Optional in-IREE checkpoints | C++ hook compiled/tested and patch checked against pinned IREE source; full IREE source rebuild remains optional/unperformed | + +The local release artifact is `dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl`. +It has not been published to PyPI. The wheel bundles the native executable and its nonbaseline +compression libraries; users need no LLVM SDK, `opt`, Node or CMake. This first wheel is +validated on Linux x86-64/glibc 2.35/Python 3.10 and is not an audited manylinux release. + +Validation completed during implementation: + +- 21 Python tests covering existing architecture/LLVM behavior, fused/ambiguous source + locations, assembly location resets, selectors, JSON/error contracts, input preservation, + output protection and offline revision resolution. +- Native CTest: standalone/plugin agreement, unchanged normalized LLVM IR, inline frames, + def-use/memory records, annotated IR validation, malformed input rejection, object/DWARF + bounds and optional before/after checkpoint hook. +- Fresh matmul compilation with required native analysis and exact-input benchmarking. +- A separate environment installed the wheel and compiled matmul from outside the checkout + with no SDK on PATH. Its executable matched NumPy matrix multiplication on the saved + tensors (maximum absolute error 0 in that run). Dependencies were copied locally for this + offline install check; fresh internet dependency resolution was not exercised. +- Offline tiny-GPT2 capture at commit `5f91d94bd9cd7190a9f3216ff93cd1dd95f2c7be`, sequence + length 8: all 17 dispatch kernels, 58 stages, required native analysis succeeded. + Optimized LLVM: 3,253 instructions / 892 source-associated; assembly: 2,462 instructions / + 1,086 source-associated. These are distinct measures, not an optimization score. +- Native object lookup on a fresh matmul object resolved a function-section byte offset to + captured dispatch and Torch-input locations. +- Installed HTTP server exposes the saved artifact, workload index, bundled assets and API. + Headless browser verification passed through workload selection, architecture, compiler + workspace, Monaco and its bundled worker, with no page errors or failed requests. +- Installed local Python factory compilation with full capture and required native analysis + succeeded; this covers the third input path and the full-log mode. + +Scope decisions and remaining research: + +- Snapshot source attribution is implemented. General instruction identity propagation, + metadata preservation through arbitrary optimizations and per-machine-instruction compiler + instrumentation are future research, as in the accepted plan. +- The plugin loads into matching LLVM `opt`; it is not loaded into the stock IREE wheel. + See [native/iree/README.md](native/iree/README.md) for the optional source-build integration. +- Full capture retains all requested logs/resources; standard capture trims display constants + and selects pass snapshots. Both retain authoritative exports in `_full/` for reproducibility. +- Existing browser components and visual design are unchanged. + +## Accepted design + +## 1. Recommended direction + +Build CompilerLens into a **pip-installable compiler inspection tool with a native LLVM provenance pass**. + +The main workflow should be: + +```bash +pip install compilerlens + +compilerlens compile sshleifer/tiny-gpt2 --out runs/gpt2 +compilerlens inspect runs/gpt2 +compilerlens trace runs/gpt2 --module transformer.h.0.mlp.c_fc --to asm +compilerlens view runs/gpt2 +``` + +The terminal exposes the same model structure, stages, IR, diffs, evidence, and lineage that the webpage already presents. `view` serves the existing webpage from the installed package. No new dashboard or experiment screen is required. + +The native contribution is a **real LLVM New Pass Manager analysis/reporting pass written in C++, built with CMake**. It inspects LLVM instructions, debug locations, inline chains, and def-use relationships, then feeds CompilerLens's provenance index. Assembly tracing uses emitted `.file`/`.loc` information. Object-file DWARF address mapping is the first stretch goal. + +The finals story becomes: + +> “CompilerLens lets you inspect a model from the command line and follow compiler-recorded provenance through MLIR, LLVM IR, and assembly. Our C++ LLVM pass exposes structured instruction provenance and audits where information is missing.” + +This is compiler analysis work. It does not need to change model arithmetic or promise a speedup to be technically substantial. + +| Priority | Deliverable | +|---|---| +| Must | Installable CLI exposing existing exploration capabilities | +| Must | C++ LLVM provenance/reporting pass, CMake project, loadable plugin, focused tests | +| Must | Native pass integrated into the CLI's capture/import pipeline | +| Must | Forward and reverse source/assembly lookup for supported x86 assembly dumps | +| Must | Existing webpage opens the same artifact and displays added assembly lineage | +| Stretch 1 | Object address -> DWARF location -> model-operation lookup | +| Stretch 2 | Pass invocation inside a source-built IREE LLVM optimization pipeline | +| Later | General origin propagation across transformations, machine-instruction instrumentation, optimization passes | + +## 2. Exactly what parameters will users pass? + +Separate **creating a run** from **querying a saved run**. Inspection commands should never recompile or download anything. + +### 2.1 `compile`: model -> saved compiler run + +```bash +compilerlens compile sshleifer/tiny-gpt2 \ + --seq-len 16 \ + --cpu host \ + --out runs/gpt2 +``` + +Only the model ID is required. Print resolved defaults in the summary and record them in the manifest. + +| Parameter | Proposed default | Meaning | +|---|---|---| +| `MODEL_ID` | Required for HF input | Hugging Face repository ID using the current supported text-model adapters | +| `--seq-len N` | `16` | Static sequence length used during export; not an inference-time maximum | +| `--revision REF` | Resolve current revision to a commit | Pin the model version; always save the resolved commit | +| `--cpu NAME` | `host` | CPU target for IREE's existing LLVM CPU backend | +| `--out DIR` | Fresh directory under `./compilerlens-runs/` | Persistent outputs; refuse accidental overwrite | +| `--capture standard\|full` | `standard` | Standard keeps current named stages and selected pass snapshots with display trimming; full retains all requested pass dumps and resources | +| `--offline` | Off | Use cached resources; fail clearly if the model is absent | +| `--seed N` | `0` | Reproduce generated example inputs; save actual tensors too | +| `--lineage auto\|required\|off` | `auto` | Auto runs compatible native provenance and reports fallback; required fails if it cannot run; off skips extra lineage analysis | + +`--lineage` is an advanced reproducibility/troubleshooting control. A supported complete installation should use the native path automatically. + +Support the existing built-in examples as mutually exclusive input sources: + +```bash +compilerlens compile --example matmul --out runs/matmul +compilerlens compile --example linear_relu --out runs/linear-relu +``` + +A local Python factory is useful after those paths work: + +```bash +compilerlens compile --python workload.py:build --out runs/local +``` + +Define `build()` to return `(torch_module, example_inputs)`. This executes the user's local Python code. `MODEL_ID`, `--example`, and `--python` must be mutually exclusive. HF-only arguments such as `--seq-len` must not silently alter local-factory inputs. + +For the finals, compilation targets Linux x86-64 CPU. Do not expose unimplemented GPU/device choices. Keep existing advanced IREE controls in the Playground; an optional CLI allowlist can follow the core commands. No tiling/search-budget parameters are needed for this release. + +### 2.2 `inspect`: the webpage's information in the terminal + +```bash +compilerlens inspect runs/gpt2 +compilerlens inspect runs/gpt2 --show architecture +compilerlens inspect runs/gpt2 --show stages +compilerlens inspect runs/gpt2 --show evidence +compilerlens inspect runs/gpt2 --show ops --stage torch-input +compilerlens inspect runs/gpt2 --show ir --stage llvm-optimized --lines 100:140 +compilerlens inspect runs/gpt2 --show ir --stage target-asm +``` + +| Parameter | Meaning | +|---|---| +| `RUN` | Saved run directory or supported artifact JSON | +| `--show summary\|architecture\|stages\|evidence\|ops\|ir` | Existing view to render; default `summary` | +| `--stage ID` | Stage name/ID from `--show stages`; required when an IR/operation query needs disambiguation | +| `--module PATH` | Restrict model-aware results to an existing module mapping | +| `--lines START:END` | Restrict displayed IR to a 1-based line range | +| `--format text\|json` | Human-readable output or structured data; default `text` | + +Use Rich for tables, trees, progress, and source highlighting. Keep output useful without color. Long IR can use the user's pager interactively; piped output should remain plain and unpaginated. Progress/diagnostics go to stderr so JSON stdout stays parseable. + +Stage identifiers come from the run. `inspect --show stages` lists what was actually captured; do not assume every model has the same number of stages. + +### 2.3 `trace`: forward and reverse provenance + +```bash +# Choose an operation from inspect --show ops. +compilerlens trace runs/gpt2 --op OP_ID --to asm + +# Group traces for operations owned by a model module. +compilerlens trace runs/gpt2 --module transformer.h.0.mlp.c_fc --to asm + +# Concrete source selector for the existing matmul fixture. +compilerlens trace runs/matmul --source torch-input:3:10 --to asm + +# Reverse lookup from an emitted assembly listing line. +compilerlens trace runs/matmul --from-stage target-asm --line 106 +``` + +`OP_ID` is a placeholder for a saved operation identifier, not an operation-name guess. Module paths also come from `inspect --show architecture`. + +| Parameter | Meaning | +|---|---| +| `--op ID` | One saved source-operation identity | +| `--module PATH` | Mapped source operations owned by a model module | +| `--source STAGE:LINE:COLUMN` | Explicit location in the run's source/IR anchor | +| `--to llvm\|asm\|all` | Destination view; default `all` | +| `--from-stage ID --line N` | Reverse lookup from a displayed IR/assembly line | +| `--format text\|json` | Human trace or complete provenance records | + +Forward selectors are mutually exclusive; reverse lookup is a separate selector mode. Multiple explicit origins must remain a set instead of becoming one arbitrarily chosen owner. + +Show the evidence chain: + +```text + torch.aten.matmul torch-input:3:10 + -> dispatch source dispatch_0.mlir:18:8 + -> LLVM locations compiler-recorded DILocation references + -> x86 assembly .file/.loc associations at instruction lines + + Relationship: source attribution recorded by the compiler + Missing links: shown explicitly +``` + +Several IR or machine instructions can share one location. This is not a one-to-one instruction ancestry chain. + +### 2.4 Remaining commands + +| Command | Example | Purpose | +|---|---|---| +| `diff` | `compilerlens diff RUN --from STAGE_A --to STAGE_B --mode text` | Existing textual/coarse semantic comparison; enforce compatible tracks | +| `import` | `compilerlens import examples/matmul --out runs/imported` | Build an artifact and run available native/assembly analyses on existing dumps without recompilation | +| `view` | `compilerlens view RUN --port 8000 --open` | Serve the bundled current webpage and selected run locally | +| `doctor` | `compilerlens doctor` | Check native analyzer, IREE, Python dependencies, versions, and supported features | + +`diff --mode text|semantic` reuses current behavior; semantic means the existing operation/dialect summary, not a proof of program equivalence. `view` defaults to loopback and prints its URL; `--open` is optional for remote/headless use. + +An existing-benchmark wrapper, such as `bench RUN --workers 8 --repetitions 10`, is secondary. Before exposing existing benchmarking more broadly, fix its constant-input substitution so decoder attention masks retain their intended values. Do not expand this release into autotuning. + +The ordinary user needs four commands: + +```text +compile MODEL --out RUN create it +inspect RUN understand what was captured +trace RUN --op ID --to asm follow an operation +view RUN inspect it in the existing webpage +``` + +## 3. What the C++ LLVM pass should actually do + +### 3.1 Implement one substantial pass first + +Use **`compilerlens-provenance`** as the name of a module-level New Pass Manager reporting pass. Share its implementation with the packaged native analyzer. + +For each LLVM instruction, emit: + +- snapshot/module identity, function, basic block, and instruction ordinal; +- opcode, result type, operand instruction references, and relevant call information; +- whether it reads/writes memory and basic vector-type information; +- available `DebugLoc`/`DILocation` information: file, directory, line, column, discriminator, scope, and inline call chain; +- whether the location is absent, has line zero, names an external helper, or resolves to a captured dispatch source; +- information needed to connect the instruction to a stable display location and compiler-produced source anchor. + +Use LLVM APIs such as `Module`, `Function`, `Instruction`, `DebugLoc`, `DILocation`, `DIScope`, `DIFile`, and operand/use traversal. This replaces fragile textual metadata interpretation for the native path. + +The pass should be **read-only**, return `PreservedAnalyses::all()`, and leave the executable-producing module unchanged. An analysis/reporting pass is a real compiler pass; changing arithmetic is not a prerequisite. + +### 3.2 Produce a useful provenance audit + +Report distinct measures: + +1. Instructions with compiler debug locations. +2. Instructions whose locations resolve to a captured dispatch source. +3. Instructions whose dispatch source resolves to a Torch-input anchor. +4. Instructions whose anchor has a supported model-module mapping. +5. Reasons for the remaining gaps. + +Separate dispatch functions from runtime/library helpers. An increase in unlocated instructions after library linking is not evidence that model lineage was lost. + +Analyze the existing `codegen`, `linked`, and `optimized` LLVM snapshots. Report coverage changes between checkpoints, but do not identify a specific destructive pass unless that pass was actually instrumented in the same compiler execution. + +Instruction IDs must be snapshot-local: module-content hash, function, block, and ordinal. Similarly named instructions or equal source locations do not establish persistent identity across optimization. + +### 3.3 Separate data dependencies from provenance + +The native pass can expose real def-use edges. They answer “which LLVM values feed this instruction?” They do not establish which source operation created it. + +Do not fill missing provenance by taking operand-origin unions and presenting them as compiler-recorded ownership. Values contribute to later operations, and fusion, rematerialization, common-subexpression elimination, and inlining complicate ownership. + +Arbitrary `!compilerlens.origin` metadata is not guaranteed to survive LLVM optimization or machine-code emission. A general metadata-propagation scheme is a later research task. + +### 3.4 Preserve identity for reliable source joins + +Keep full file identities, columns, inline chains, and explicit origin sets in a native sidecar. Do not key everything by bare line numbers or dispatch basenames. + +The existing architecture mapping remains the bridge from Torch operations to model modules. An LLVM pass cannot reconstruct PyTorch ownership from stripped LLVM IR alone. + +For relocated files, use the run manifest and unique aliases. If captured files share a basename, resolve by full identity/hash or report ambiguity. Never join a C++ helper's line 18 to Torch-input line 18 just because the numbers agree. + +Distinguish **debug source lines** from **physical `.ll` display lines**. A practical solution is for the native analyzer to emit an annotated LLVM text view with instruction-ID comments using LLVM's assembly-printing support. Ingest maps those markers to display lines; preserve the original dump separately. This avoids brittle matching of multiline instructions. + +## 4. How the pass integrates with IREE + +There are three distinct integrations. Describe them accurately in code, documentation, and the presentation. + +### A. Default release: native analysis of actual IREE outputs + +```mermaid +flowchart LR + A[CompilerLens CLI] --> B[Existing Turbine and IREE compilation] + B --> C[LLVM IR, dispatch MLIR, assembly, object] + C --> D[C++ LLVM provenance pass in compilerlens-native] + C --> E[Assembly location resolver] + D --> F[Provenance sidecar and existing artifact] + E --> F + F --> G[Rich CLI queries] + F --> H[Existing webpage] +``` + +The Python package invokes a private executable, `compilerlens-native`. It parses IREE's LLVM output and runs the same New Pass Manager pass used by the developer plugin, writing versioned JSON for ingest. + +This is a native LLVM pass integrated into CompilerLens's pipeline. It is not injected into the IREE wheel's optimizer. It analyzes actual captured IREE output and does not re-optimize a substitute program. + +A private native executable also avoids requiring users to find a compatible system `opt` or load C++ code into IREE's process. + +### B. Developer plugin: conventional LLVM integration + +Build `CompilerLensPasses.so` and load it with the `opt` from the same LLVM build: + +```bash +/path/to/llvm/bin/opt \ + -load-pass-plugin=build/native/CompilerLensPasses.so \ + -passes=compilerlens-provenance \ + -disable-output \ + run/llvm/module.optimized.ll > provenance.json +``` + +The pass name, CMake target, registration callback, and tests provide visible compiler implementation. JSON must be the only stdout output; `-disable-output` prevents bitcode from being mixed into it. + +A plugin built against LLVM 22 is not automatically safe inside LLVM 23. Match the plugin to its host's build/API/ABI settings. Parsing another version's textual IR in a separate process is a different compatibility question that also requires testing. + +### C. Optional invocation inside source-built IREE + +The pinned IREE source constructs its real LLVM optimization pipeline here: + +```text +compiler/plugins/target/LLVMCPU/LLVMIRPasses.cpp + runLLVMIRPasses(...) + PassBuilder + buildPerModuleDefaultPipeline(...) +``` + +Link the pass's reusable core and insert reporting passes around that actual pipeline. Cover O0 too: the current implementation conditionally skips building the optimization pipeline at O0. + +A later extension can register `PassInstrumentationCallbacks` for before/after-pass coverage snapshots. This reports observed coverage changes; per-instruction loss attribution still needs careful identity handling. + +Gate this on an available compatible IREE source/build tree and build time. The pip release should not require every user to rebuild IREE. + +**Do not advertise `iree-compile --load-pass-plugin=...` as working integration.** The inspected binary does not expose that standard `opt` option, and the pinned LLVM pipeline does not register external pass-plugin callbacks. Generic `--load` and IREE compiler plugins are not equivalent to LLVM New Pass Manager registration. + +## 5. Concrete C++ and CMake structure + +```text +native/ + CMakeLists.txt + include/compilerlens/Provenance.h + lib/Provenance.cpp # LLVM traversal, locations, audit + lib/Plugin.cpp # pass registration + tools/compilerlens-native.cpp + tests/ + locations.ll + inline-chain.ll + missing-debug.ll + duplicate-filenames.ll + multiline-instruction.ll + check_reports.py +``` + +For the first plugin milestone, this CMake structure is sufficient: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(CompilerLensNative LANGUAGES C CXX) + +find_package(LLVM REQUIRED CONFIG) +set(COMPILERLENS_LLVM_MAJOR 22 CACHE STRING "Validated LLVM major") +if(NOT LLVM_VERSION_MAJOR EQUAL COMPILERLENS_LLVM_MAJOR) + message(FATAL_ERROR "Use the validated LLVM development toolchain") +endif() + +list(APPEND CMAKE_MODULE_PATH "${LLVM_CMAKE_DIR}") +include(AddLLVM) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +include_directories(SYSTEM ${LLVM_INCLUDE_DIRS}) +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/include") +add_definitions(${LLVM_DEFINITIONS}) + +add_llvm_pass_plugin(CompilerLensPasses + lib/Plugin.cpp + lib/Provenance.cpp +) + +include(CTest) +# Add fixture/report checks using this LLVM build's opt. +``` + +The private executable links the same provenance implementation with LLVM `Core`, `IRReader`, `Analysis`, `Passes`, and `Support`. Use LLVM's CMake helpers to preserve RTTI/exception and link settings. Add `Object`/`DebugInfoDWARF` when implementing address lookup. Install the executable into the native wheel's private binary directory. + +Registration is ordinary New Pass Manager code: + +```cpp +#include "compilerlens/Provenance.h" +#include "llvm/Passes/PassBuilder.h" +#include "llvm/Plugins/PassPlugin.h" // Validated LLVM 22 layout. + +extern "C" LLVM_ATTRIBUTE_WEAK llvm::PassPluginLibraryInfo +llvmGetPassPluginInfo() { + return { + LLVM_PLUGIN_API_VERSION, "CompilerLensPasses", "0.1.0", + [](llvm::PassBuilder &PB) { + PB.registerPipelineParsingCallback( + [](llvm::StringRef Name, llvm::ModulePassManager &MPM, + llvm::ArrayRef) { + if (Name != "compilerlens-provenance") return false; + MPM.addPass(compilerlens::ProvenancePass()); + return true; + }); + } + }; +} +``` + +Older examples use `llvm/Passes/PassPlugin.h`. The available LLVM 22.1.8 uses `llvm/Plugins/PassPlugin.h` and plugin API version 2. Pin to the tested toolchain instead of copying arbitrary tutorial headers. + +```bash +cmake -S native -B build/native -G Ninja \ + -DLLVM_DIR=/path/to/llvm/lib/cmake/llvm \ + -DCMAKE_BUILD_TYPE=Release +cmake --build build/native --parallel 2 +ctest --test-dir build/native --output-on-failure +``` + +Compiler selection and C++ standard-library discovery must be explicit on machines with multiple toolchains. The local smoke test needed Clang's `--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/11`; that is a local build setting, not a flag to hardcode for everyone. + +## 6. Extending lineage through assembly + +### 6.1 First deliverable: emitted assembly listing + +The repository's `.s` files already contain records such as: + +```asm +.file 1 "dumps" "module_main$async_dispatch_0.mlir" +.loc 1 18 8 + ... + vfmadd231ps ... +``` + +The join is: + +```text +assembly instruction line + -> current .loc file/line/column + -> captured dispatch MLIR line + -> original loc(...) in that dispatch dump + -> Torch-input operation + -> model module, where ownership is available +``` + +The current `parse_asm()` discards these directives. Extend it with a conservative location-state parser and feed associations into the existing lineage builder. + +Required details: + +- Support actual numbered `.file` forms emitted by the pinned x86 toolchain, including directory/name forms and escaped filenames. +- Carry `.loc` through ordinary instructions until it changes. +- Treat line zero as unavailable and clear the preceding source association. +- Reset state at relevant function/section boundaries; avoid assigning the previous function's last location to a new prologue. +- Do not clear valid locations at ordinary internal labels. +- Preserve available discriminator/statement metadata in the sidecar. +- Exclude directives, data, labels, and alignment padding from mnemonic-level coverage. +- Report unsupported syntax instead of guessing. + +An inherited `.loc` is the assembler's source association. It does not prove each machine instruction has an independent corresponding LLVM `!dbg` attachment. LLVM-instruction and assembly coverage can therefore differ. + +### 6.2 Stretch: actual object-file addresses + +LLVM IR passes run before instruction selection/register allocation and cannot see final code addresses. Address tracing needs a separate object/debug-info reader. + +Preserve the **actual object emitted by IREE**. Use LLVM Object/DWARF APIs, or validated tools during prototyping, to join: + +```text +(object identity, section, address range) + -> DWARF line-table row / inline frames where present + -> dispatch MLIR source + -> source operation and model module +``` + +Relocatable `.o` files can have address zero in several sections. Preserve section identity and relocation handling. Linked objects have their own addresses; runtime addresses additionally need the correct load mapping. Do not reuse `.o` offsets as VMFB/runtime addresses. + +DWARF rows define ranges, usually ending at the next row within a sequence. Handle `end_sequence`, line zero, duplicate-address rows, and uncovered ranges. Cross-check supported cases with `llvm-dwarfdump` and `llvm-objdump`. + +A later command could be: + +```bash +compilerlens trace RUN --object OBJECT_ID --section SECTION --address 0xOFFSET +``` + +This must not delay the LLVM pass and `.s` tracing. Do not regenerate assembly with an unrelated `llc` and present it as IREE's emitted assembly. + +## 7. Use the existing webpage + +Keep the React screens, navigation, panes, and styling. Retain browser artifact version 0.7 initially. + +The schema already supports assembly stages, instruction line numbers, `Operation.source_loc`, lineage highlight maps, evidence, gap notes, and artifact notes. Populate those fields from the new backend data. + +For a unique resolved origin, set `source_loc`. For multiple explicit origins, retain the set in the sidecar and let the lineage builder add the operation's display line under each supported source anchor without inventing one scalar owner. Leave unresolved associations absent. + +Browser source-line grouping is coarser than the CLI's file/line/column identities. Explain that limit through existing notes. Full native records remain in `provenance.json`, available to CLI queries without a frontend schema migration. + +Packaging requires build/server plumbing: + +1. Bundle existing Vite production assets and Monaco workers. +2. Serve `/artifacts/index.json` and the selected run's artifact from Python. +3. For installed builds, set the existing `VITE_SANDBOX_API` override to an empty string so the existing client uses same-origin routes. Mount API routes before static fallback. +4. Delegate capture/compile API calls to shared services using configurable run paths. +5. Build release assets without copying the entire generated `frontend/public/artifacts` collection into the wheel. + +`view RUN` can open the current landing page with that run listed. New routing, selected-operation deep links, and new UI controls are unnecessary for the finals. + +## 8. Pip packaging, including the native component + +### 8.1 One primary installation + +For the finals, make `pip install compilerlens` the complete supported installation. Include Python CLI/compiler dependencies and a prebuilt native analyzer for the advertised platform. A separate lightweight viewer can follow later. + +Internally, use two distributions if useful: + +```text +compilerlens Python CLI, ingest, adapters, bundled webpage + depends on: +compilerlens-native private executable and native runtime needs +``` + +End users should not need LLVM headers, CMake, Ninja, or system `opt`. Developers building the native package from source need those dependencies. Publish supported platform wheels and test installation using the binary native artifact. + +Start with **Linux x86-64 / Python 3.10**. Broader platforms follow actual build and installation tests. + +### 8.2 Compatibility strategy + +Installed IREE package 3.11.0 reports compiler commit `e4a3b0405d7d23554da26403658d0e8c3c5ecf25` and LLVM 23.0.0git. The development installation used for the pass smoke test is LLVM 22.1.8. + +LLVM 22.1.8 successfully parsed the tested IREE textual dumps. This does not establish general forward IR compatibility. Pin and test the exact producer/analyzer combination. Reject unsupported IR or rejected debug information; do not strip unfamiliar attributes just to make parsing succeed. + +The standalone analyzer does not share an ABI with IREE. The plugin must be loaded by its matching LLVM 22.1.8 `opt`; an in-IREE build needs IREE's own exact LLVM dependency/build settings. + +Version the JSON protocol. Record producer compiler, analyzer LLVM, pass version, and input hashes. Reject incompatible sidecar versions clearly. + +### 8.3 Release work + +- Add `pyproject.toml`, the console entry point, and a `compilerlens` namespace. Avoid installing generic top-level `models`, `backend`, and `ingest` packages. +- Reuse current modules under that namespace; make the import/path move early and avoid unrelated refactoring. +- Share capture/artifact services between CLI and FastAPI. +- Read installed assets through `importlib.resources`; write runs to `--out`, never into `site-packages`. +- Keep direct dependency metadata separate from the pinned development environment snapshot. +- `requirements.txt` uses a CPU PyTorch extra index. Package metadata cannot copy that pip index directive. Test the default resolver and document CPU-only preinstallation if needed. The complete install may be large; do not promise a small download. +- Build native wheels in a suitable Linux release environment. Inspect dependencies and RPATHs; do not ship references to this machine's `/pkg/qct/software/...` paths. +- Include required LLVM/third-party license notices and native runtime libraries in the release process. +- Check package names on Day 1 and produce local installable wheels even if publication is pending. Advertise public pip installation only once the distributions exist. + +## 9. Run layout and implementation map + +```text +runs/run-id/ + manifest.json versions, commands, model, target, input identities + artifact.json existing browser contract + provenance.json richer native/assembly relationships + source/ source anchor and module ownership + mlir/ existing stage captures + llvm/ original .ll, .s, dispatch source, retained .o + native/ annotated views and per-snapshot pass reports + inputs/ actual example tensors + logs/ +``` + +Preserve exact dispatch-source text referenced by LLVM and assembly. Reformatting/trimming that authoritative file can change line numbers and corrupt joins. Separate display copies can be trimmed with explicit mapping back to authoritative coordinates. + +| Existing location | Planned change | +|---|---| +| `scripts/compile_hf_model.py` | Put orchestration behind `compile`; generate artifacts automatically | +| `backend/compiler/runner.py` | Capture debug artifacts; invoke native analyzer after compilation | +| `native/` | New C++ pass, plugin, native driver, CMake, fixtures | +| `ingest/llvm_parser.py` | Add `.file`/`.loc` associations; retain current metadata parser as explicit fallback | +| `ingest/build.py` | Join native and assembly reports into existing stages/evidence | +| `ingest/lineage.py` | Validate source identities and accept explicit origin sets while retaining browser-compatible output | +| `ingest/workloads/generated.py` | Retain actual executable/function/file relationships for traces rather than using an unrelated representative kernel | +| `models/architecture.py` | Reuse exact ownership capture; local-factory support only if time permits | +| `backend/api/` | Serve packaged assets/runs; share capture services | +| `frontend/` | Release build configuration only; existing UI and interactions remain | + +Hash authoritative files. Type relationships explicitly: compiler source association, inline call site, data dependency, or unresolved. Do not use instruction IDs as cross-snapshot identities. + +## 10. What was actually tested for this direction + +These were feasibility checks in temporary files, not completed product features: + +1. **CMake/plugin build worked.** A small C++ New Pass Manager reporting pass was built with LLVM/Clang 22.1.8, CMake 3.31.5, and Ninja. It exports `llvmGetPassPluginInfo`, registers a module pass, and returns `PreservedAnalyses::all()`. +2. **Plugin loading worked.** Matching `opt` loaded it and ran it on matmul and tiny GPT-2 optimized LLVM dumps. +3. **Real instruction metadata was accessible.** The small audit counted 1,697 instructions / 108 with nonzero debug lines in the existing matmul optimized dump, and 3,702 / 1,492 in tiny GPT-2. These are whole-dump instruction/debug counts, not model-origin coverage or lineage-quality scores. +4. **Fresh IREE dumps were readable.** The pass parsed newly captured matmul codegen, linked, and optimized textual LLVM files. +5. **Assembly provenance exists.** The existing matmul's `vfmadd231ps` at listing line 106 resolves through dispatch source line 18 to Torch-input `3:10` in a small exploratory resolver. This is a checked example, not validated general assembly coverage. +6. **Object DWARF exists.** Fresh IREE compilation with executable debug level 3 and CPU debug symbols produced an object and linked executable with readable DWARF line tables. LLVM tools resolved addresses to dispatch source. +7. **The IREE integration boundary was checked.** Its exact LLVM pipeline source constructs its own `PassBuilder` and does not automatically install external pass-plugin callbacks. + +Temporary code/build/artifacts are under `/tmp/compilerlens-native-plan-check`. No C++ product or frontend implementation was added to the repository during planning. Portable wheels, the full provenance pass, CLI, and general assembly resolver remain implementation work. + +## 11. Five-day schedule + +Assume 6–7 focused hours per contributor per day. Keep one native pass plus its CLI adapter on the critical path, and reserve the final afternoon for rehearsal. + +| Day | Compiler/native owner | CLI/package owner | Required result | +|---|---|---|---| +| **1: Interfaces and builds** | Reproduce pass build; define JSON; validate IR compatibility; native-driver skeleton | Freeze CLI help/arguments; namespace/import move; capture/import and summary; installed viewer skeleton | C++ pass runs on real IR; installed CLI captures/imports a run | +| **2: Lineage bridge** | Debug/inline/file records; source joins; assembly location state; negative fixtures | Rich architecture/stages/evidence/IR output; manifest; native invocation and diagnostics | Known matmul source traces to emitted assembly | +| **3: Complete native workflow** | Native executable sharing pass core; stable display mapping; audit reasons; reverse lookup | Trace, diff, doctor, view; native wheel assembly; enrich existing artifact schema | Native CLI and unchanged webpage consume the same run | +| **4: Validate and package** | Second workload; unknown/ambiguous/inline cases; LLVM-tool comparisons; address stretch only if core is green | Clean install outside checkout; runtime dependency checks; offline assets; browser regression | Installable wheels and reproducible two-workload demo | +| **5: Freeze and present** | Fix blocking provenance issues; freeze artifacts/toolchain | Final help/examples; offline demo; local wheel backup; recording/rehearsal | Repeatable CLI -> C++ pass evidence -> assembly -> current webpage demo | + +### Scope gates + +- **Day 1:** prove the native-driver skeleton can be packaged without the machine's LLVM paths. Native portability cannot wait until Day 5. +- **Day 2:** finish `.s` tracing before object-address lookup. Available DWARF data does not make the complete address feature mandatory. +- **Day 3:** if an IREE source build is not already working, ship standalone native-pass integration and matched-`opt` plugin. Document the internal hook without claiming it shipped. +- **Day 4:** cut local-factory support, object-address lookup, and cosmetic CLI work before native tests, source-identity checks, or wheel tests. +- **Solo version:** HF/built-in capture, summary/IR inspection, one native reporting pass, one assembly trace, existing browser. Reduce optional CLI views and stretch features. + +## 12. Tests and acceptance criteria + +### Native pass + +- Check file/line/column/inline-chain reports against LLVM fixtures. +- Cover missing metadata, line zero, helper scopes, duplicate basenames, and shared locations. +- Check def-use edges without converting them into origin claims. +- Test multiline instructions and exact displayed-IR mapping. +- Compare normalized IR before/after reporting to confirm no module mutation. +- Reject incompatible IR/debug information and analyzer protocols explicitly. + +### Assembly/source resolver + +- Cover numbered `.file`, directory/name forms, inheritance, line-zero resets, internal labels, and function boundaries. +- Cover multiple files/functions, missing source files, and ambiguous aliases. +- Verify the existing matmul FMA association and a tiny-model case. +- Check forward/reverse lookup agreement. +- For optional address lookup, check section-aware ranges and `end_sequence` against LLVM tools. + +### Package and product + +- Install wheels in a new environment outside the repo with no project `PYTHONPATH` and no system LLVM on PATH. +- Run capture/import, inspect, trace, and view from that installation. +- Confirm JSON is uncontaminated by progress text and saved runs survive process restart. +- Open old and enriched artifacts in the existing webpage, including assembly highlighting. +- Confirm offline static assets/Monaco and cached-model compilation. +- Record capture time, analysis time, and artifact size. Make no performance-improvement claim for an analysis feature. + +Success means the complete chain works with honest gaps. Lower coverage after removing false associations can be a correctness improvement. “100% lineage” is not a release criterion. + +## 13. Why this is credible compiler work + +The implementation provides: + +1. A conventional LLVM pass, build targets, registration, and tests. +2. Native instruction/metadata traversal and a versioned provenance representation. +3. Def-use analysis distinguished from source attribution. +4. A join through dispatch MLIR into model ownership. +5. Assembly/debug-info handling, gap audits, and false-link regression fixtures. +6. A compatible native component packaged as a usable developer tool. + +LLVM already supplies source debugging, symbolization, and debug-info validation. We are not inventing DWARF or claiming locations prove semantic equivalence. CompilerLens contributes the model-to-compiler bridge, combined provenance queries, and consistent CLI/web artifact workflow. + +A location-auditing **MLIR pass** or **machine-function analysis pass** could follow. MLIR exposes richer fused/callsite locations earlier; machine passes see post-selection instructions. Both add build/pipeline integration work. Start with the tested LLVM pass and assembly evidence, then expand where measured gaps justify it. + +## 14. Finals demonstration + +Use roughly six minutes: + +1. Show `--help`, `doctor`, and the four-command workflow using cached dependencies. +2. Inspect model architecture, stages, and a source operation in terminal output. +3. Briefly show CMake, pass registration, and the LLVM APIs used in the C++ implementation. +4. Trace a matmul source through dispatch/LLVM evidence to an emitted FMA instruction. +5. Reverse-query that assembly line; show an unavailable/ambiguous case handled correctly. +6. Run `view RUN` and inspect the same evidence through the existing webpage. + +If address tracing is complete, add it as the technical extension. If in-IREE invocation is incomplete, accurately describe the shipped path as a native LLVM pass over IREE's captured IR, integrated into CompilerLens. + +Suggested pitch: + +> “We made CompilerLens installable and scriptable, added a C++ LLVM provenance pass, and extended source attribution into assembly. The CLI and webpage inspect the same compiler evidence.” + +## 15. References + +Reviewed [README.md](README.md), [architecture notes](../dummy/ARCHITECTURE-NOTES.md), [original design](../dummy/DESIGN-DOC.md), the compiler runner, architecture capture, LLVM/assembly parsers, lineage builder, artifact schema, frontend clients, and generated matmul/tiny-model dumps. + +External references: + +- [Writing an LLVM New Pass Manager pass](https://llvm.org/docs/WritingAnLLVMNewPMPass.html) +- [LLVM source-level debugging](https://llvm.org/docs/SourceLevelDebugging.html) +- [Updating LLVM debug information](https://llvm.org/docs/HowToUpdateDebugInfo.html) +- [llvm-dwarfdump](https://llvm.org/docs/CommandGuide/llvm-dwarfdump.html) +- [llvm-symbolizer](https://llvm.org/docs/CommandGuide/llvm-symbolizer.html) +- [Pinned IREE LLVM pipeline](https://github.com/iree-org/iree/blob/e4a3b0405d7d23554da26403658d0e8c3c5ecf25/compiler/plugins/target/LLVMCPU/LLVMIRPasses.cpp) +- [Pinned IREE LLVM CPU target/artifact emission](https://github.com/iree-org/iree/blob/e4a3b0405d7d23554da26403658d0e8c3c5ecf25/compiler/plugins/target/LLVMCPU/LLVMCPUTarget.cpp) +- [Pinned IREE CPU target options](https://github.com/iree-org/iree/blob/e4a3b0405d7d23554da26403658d0e8c3c5ecf25/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.cpp) + +Installed compiler help, LLVM headers, an actual native build, and emitted artifacts established local feasibility. Live documentation, arbitrary LLVM tutorials, and the installed IREE wheel do not necessarily expose interchangeable APIs. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ca6917c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include README.md pyproject.toml setup.py +recursive-include native *.cpp *.h *.ll *.py *.txt *.md *.cmake *.patch +recursive-include scripts *.py *.mjs +recursive-include frontend src/* +include frontend/package.json frontend/package-lock.json frontend/index.html frontend/vite.config.ts frontend/vite.release.config.ts frontend/tsconfig.json +recursive-include frontend/src *.tsx *.ts *.css +recursive-include compilerlens/_bin * +recursive-include compilerlens/_web * +global-exclude __pycache__ *.pyc diff --git a/README.md b/README.md index fe238cb..9d60a08 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,26 @@ CompilerLens is an interactive explorer for AI compiler pipelines. It compiles a Hugging Face model through IREE and presents the resulting Torch, MLIR, LLVM IR, and x86-64 stages in one navigable interface. +## CLI and native LLVM pass + +The `feature/experiments-cli` branch adds a pip-installable CLI, the bundled existing viewer, +a C++ LLVM provenance pass/plugin, source-to-assembly tracing, and object/DWARF lookup. + +```bash +python -m pip install dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl \ + --extra-index-url https://download.pytorch.org/whl/cpu +compilerlens doctor +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens inspect runs/matmul +compilerlens trace runs/matmul --module model --to asm +compilerlens view runs/matmul +``` + +The local wheel targets Linux x86-64 (validated on glibc 2.35/Python 3.10); it has not been +published to PyPI. See [CLI parameters, native development and release instructions](docs/CLI.md) +and the [implementation review](FINALS_5_DAY_PLAN.md). The Python implementation lives under +`compilerlens/`; checkout compatibility imports preserve the older developer commands below. +

CompilerLens landing page with Hugging Face model search, the MLIR lowering pipeline, and Compiler Playground

diff --git a/backend/__init__.py b/backend/__init__.py index e69de29..e01f837 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -0,0 +1,3 @@ +"""Source-checkout compatibility; installed API is compilerlens.backend.""" +from compilerlens import backend as _package +__path__ = _package.__path__ diff --git a/compilerlens/__init__.py b/compilerlens/__init__.py new file mode 100644 index 0000000..6b5458e --- /dev/null +++ b/compilerlens/__init__.py @@ -0,0 +1,13 @@ +"""CompilerLens: model-aware compiler inspection and provenance.""" + +__version__ = "0.1.0" + + +def capture(*args, **kwargs): + from .services import capture as implementation + return implementation(*args, **kwargs) + + +def load_report(path): + from .storage import load_run + return load_run(path) diff --git a/compilerlens/__main__.py b/compilerlens/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/compilerlens/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/backend/api/__init__.py b/compilerlens/backend/__init__.py similarity index 100% rename from backend/api/__init__.py rename to compilerlens/backend/__init__.py diff --git a/backend/compiler/__init__.py b/compilerlens/backend/api/__init__.py similarity index 100% rename from backend/compiler/__init__.py rename to compilerlens/backend/api/__init__.py diff --git a/backend/api/app.py b/compilerlens/backend/api/app.py similarity index 79% rename from backend/api/app.py rename to compilerlens/backend/api/app.py index a4ffbdc..06cde6e 100644 --- a/backend/api/app.py +++ b/compilerlens/backend/api/app.py @@ -19,9 +19,11 @@ import logging import re import shutil +from compilerlens.toolchain import find_tool import subprocess import tempfile import time +import threading import uuid from pathlib import Path @@ -30,8 +32,11 @@ from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field -REPO_ROOT = Path(__file__).resolve().parents[2] +from compilerlens.storage import workspace, atomic_json + +REPO_ROOT = workspace() LOGGER = logging.getLogger(__name__) +_PUBLISH_LOCK = threading.Lock() _MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._-]*)?") _PUBLIC_ERROR_LIMIT = 500 # Full-stage artifacts duplicate a model's constants into textual MLIR. The largest verified @@ -136,7 +141,7 @@ class BenchmarkRequest(BaseModel): def _tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise HTTPException(status_code=503, detail=f"'{name}' not on PATH; the server needs the project venv active.") return path @@ -179,7 +184,7 @@ def _sandbox_models() -> list[str]: seen = set(models) examples_dir = REPO_ROOT / "examples" - for path in sorted(examples_dir.glob("*/model_info.json")): + for path in sorted([*examples_dir.glob("*/model_info.json"), *(REPO_ROOT / "runs").glob("*/model_info.json")]): try: model_id = str(json.loads(path.read_text()).get("model_id") or "").strip() except (OSError, ValueError, TypeError): @@ -241,90 +246,38 @@ def explore_model(request: ExploreRequest, background: BackgroundTasks): def _run_explore(job_id: str, model_id: str, seq_len: int) -> None: - """Run the established full dump -> ingest pipeline and publish its artifact.""" + """Use the same durable capture and native provenance pipeline as the CLI.""" + from compilerlens.services import capture job = JOBS[job_id] started = time.monotonic() - phase = "looking up the model" + phase = "loading and compiling the model" try: - from backend.compiler.runner import ( - DEFAULT_STAGES, - CompilerRunner, - RunConfig, - TRIM_CODEGEN_PASSES, - TRIM_ELIDE_ATTRS, - ) - from ingest.build import build_artifact, build_index - from ingest.workloads.generated import spec_from_dump_dir - from models.detect import detect - from models.hf_wrapper import wrap, wrapper_source - - # Detect/load/build-artifact/publish, plus `CompilerRunner.run()`'s own real - # checkpoint count (export + one per compiled stage + 4 more phases -- see - # runner.py). Known up front since `_run_explore` never overrides `stages`, so the - # progress bar's denominator never has to guess or change mid-run. - outer_steps = 4 - grand_total = outer_steps + (5 + len(DEFAULT_STAGES)) - done = 0 - - def report(label: str) -> None: - nonlocal done - done += 1 - job["progress"] = {"label": label, "done": done, "total": grand_total} - - detected = detect(model_id, seq_len=seq_len) - report("Detected model architecture") - phase = "loading the model" - module, example_inputs, model_info = wrap(detected) - report("Loaded and exported the model") - param_count = int(model_info.get("param_count") or 0) - if param_count > _MAX_EXPLORE_PARAMETERS: - raise ExploreModelTooLargeError( - f"{param_count} parameters exceeds the {_MAX_EXPLORE_PARAMETERS} parameter interactive limit" - ) - output_dir = REPO_ROOT / "examples" / detected.slug - phase = "exporting and compiling the model" - runner = CompilerRunner(RunConfig( - layout="ingest", elide_attrs_larger_than=TRIM_ELIDE_ATTRS, - pass_log_after=TRIM_CODEGEN_PASSES, - )) - result = runner.run( - module, example_inputs, name=detected.slug, output_dir=output_dir, model_info=model_info, - on_progress=lambda label, _done, _total: report(label), - ) - manifest = json.loads(result.manifest_path.read_text()) - if manifest.get("errors"): - raise RuntimeError(_compiler_failure_message(manifest["errors"])) - - phase = "building the CompilerLens artifact" - report("Building the CompilerLens artifact") - (output_dir / "source.py").write_text(wrapper_source(detected)) - (output_dir / "model_info.json").write_text(json.dumps(model_info, indent=2)) - spec = spec_from_dump_dir(output_dir, model_info) - artifact = build_artifact(spec, root=output_dir) - artifacts_dir = REPO_ROOT / "frontend" / "public" / "artifacts" - artifacts_dir.mkdir(parents=True, exist_ok=True) - artifact_path = artifacts_dir / f"{spec.id}.json" - artifact_path.write_text(artifact.to_json()) - - report("Publishing") - index_path = artifacts_dir / "index.json" - existing = json.loads(index_path.read_text()) if index_path.is_file() else {"workloads": []} - entry = build_index({spec.id: spec}, {spec.id: artifact})["workloads"][0] - existing["workloads"] = [w for w in existing.get("workloads", []) if w.get("id") != spec.id] + [entry] - index_path.write_text(json.dumps(existing)) - # As in scripts/compile_hf_model.py, the untrimmed export only exists so IREE can - # compile it. Keeping it would duplicate large model weights for no viewer benefit. - full_dir = output_dir / "_full" - if full_dir.is_dir(): - shutil.rmtree(full_dir) - job.update(status="done", model_info=model_info, artifact_id=spec.id) - except Exception as exc: # noqa: BLE001 - a background job must report its failure - # The UI gets an actionable, bounded explanation. Keep the complete exception and - # traceback in the server terminal for developers diagnosing an unfamiliar model. - LOGGER.exception("Explore job %s failed while %s (%s)", job_id, phase, model_id) - job.update(status="failed", error=_friendly_explore_error(exc, model_id, phase)) + def report(label): + previous = job.get('progress', {}).get('done', 0) + job['progress'] = {'label': label, 'done': previous + 1, 'total': max(previous + 2, 24)} + output_dir = capture(model_id, seq_len=seq_len, + out=REPO_ROOT / 'runs' / job_id, + max_parameters=_MAX_EXPLORE_PARAMETERS, progress=report) + info = json.loads((output_dir / 'model_info.json').read_text()) + artifact = json.loads((output_dir / 'artifact.json').read_text()) + artifact_id = artifact['compilation_id'] + artifacts_dir = REPO_ROOT / 'frontend/public/artifacts' + atomic_json(artifacts_dir / f'{artifact_id}.json', artifact) + # Serialise the read-modify-write if several background jobs finish together. + with _PUBLISH_LOCK: + index_path = artifacts_dir / 'index.json' + existing = json.loads(index_path.read_text()) if index_path.exists() else {'workloads': []} + entry = json.loads((output_dir / 'index.json').read_text())['workloads'][0] + existing['workloads'] = [w for w in existing['workloads'] if w['id'] != artifact_id] + [entry] + atomic_json(index_path, existing) + job.update(status='done', model_info=info, artifact_id=artifact_id, + progress={'label': 'Complete', 'done': 1, 'total': 1}) + except Exception as exc: + LOGGER.exception('Explore job %s failed (%s)', job_id, model_id) + job.update(status='failed', error=_friendly_explore_error(exc, model_id, phase)) finally: - job["compile_seconds"] = round(time.monotonic() - started, 2) + job['compile_seconds'] = round(time.monotonic() - started, 2) + atomic_json(REPO_ROOT / 'jobs' / f'{job_id}.json', job) def _normalize_model_id(value: str) -> str: @@ -423,22 +376,23 @@ def _run_compile(job_id: str, request: CompileRequest, flags: list) -> None: try: import iree.turbine.aot as aot - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap detected = detect(request.model_id, seq_len=request.seq_len) module, example_inputs, model_info = wrap(detected) job["model_info"] = model_info - work_dir = Path(tempfile.mkdtemp(prefix=f"sandbox-{job_id}-")) + work_dir = REPO_ROOT / "jobs" / job_id + work_dir.mkdir(parents=True, exist_ok=False) job["work_dir"] = str(work_dir) mlir_path = work_dir / "input.mlir" mlir_path.write_text(str(aot.export(module, *example_inputs).mlir_module)) - from backend.measure.compare import _input_specs + from compilerlens.backend.measure.compare import _input_specs - job["input_specs"] = _input_specs(example_inputs) + job["input_specs"] = _input_specs(example_inputs, work_dir) base = [ _tool("iree-compile"), @@ -486,7 +440,7 @@ def _run_compile(job_id: str, request: CompileRequest, flags: list) -> None: def _signals(work_dir: Path) -> dict: - from backend.measure.compare import _read_signals + from compilerlens.backend.measure.compare import _read_signals return _read_signals(work_dir) @@ -534,7 +488,7 @@ def benchmark_job(job_id: str, request: BenchmarkRequest): if job["status"] != "done": raise HTTPException(status_code=409, detail=f"job is '{job['status']}'") - from backend.measure.bench import BenchmarkError, benchmark_module + from compilerlens.backend.measure.bench import BenchmarkError, benchmark_module vmfb = Path(job["work_dir"]) / "module.vmfb" try: diff --git a/backend/api/run_server.py b/compilerlens/backend/api/run_server.py similarity index 96% rename from backend/api/run_server.py rename to compilerlens/backend/api/run_server.py index 7aa08ff..50ce780 100644 --- a/backend/api/run_server.py +++ b/compilerlens/backend/api/run_server.py @@ -50,7 +50,7 @@ def main() -> int: print(f" GET /compile/{{id}} status + signals") print(f" GET /compile/{{id}}/stage/{{stage}} raw IR") print(f" POST /compile/{{id}}/benchmark opt-in timing") - uvicorn.run("backend.api.app:app", host=HOST, port=PORT, reload=False) + uvicorn.run("compilerlens.backend.api.app:app", host=HOST, port=PORT, reload=False) return 0 diff --git a/backend/measure/__init__.py b/compilerlens/backend/compiler/__init__.py similarity index 100% rename from backend/measure/__init__.py rename to compilerlens/backend/compiler/__init__.py diff --git a/backend/compiler/runner.py b/compilerlens/backend/compiler/runner.py similarity index 97% rename from backend/compiler/runner.py rename to compilerlens/backend/compiler/runner.py index 7ee7114..b8aeefb 100644 --- a/backend/compiler/runner.py +++ b/compilerlens/backend/compiler/runner.py @@ -9,6 +9,7 @@ import json import re import shutil +from compilerlens.toolchain import find_tool import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -82,6 +83,7 @@ def _strip_dialect_resources(text: str) -> str: class RunConfig: target_backend: str = "llvm-cpu" target_cpu: str = "host" + debug_symbols: bool = False stages: tuple = DEFAULT_STAGES # "flat" keeps everything in one directory (the original behaviour, used by the # hand-written examples). "ingest" writes the mlir/ llvm/ passes/ layout that @@ -122,7 +124,7 @@ def __init__(self, config: RunConfig = None): @staticmethod def _require_tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise RuntimeError( f"'{name}' not found on PATH. Activate the project venv " @@ -246,7 +248,7 @@ def _export_torch_input( # Export exactly once and pass that same decomposed program into Turbine. Besides # avoiding duplicate tracing, this preserves nn_module_stack metadata long enough # to establish an exact module-to-Torch-MLIR mapping. - from models.architecture import capture_architecture, export_program + from compilerlens.models.architecture import capture_architecture, export_program exported_program = None if model_info is not None: @@ -324,6 +326,7 @@ def _dump_llvm_intermediates(self, torch_input_path: Path, output_dir: Path, dum str(torch_input_path), f"--iree-hal-target-backends={self.config.target_backend}", f"--iree-llvmcpu-target-cpu={self.config.target_cpu}", + *(["--iree-hal-executable-debug-level=3", "--iree-llvmcpu-debug-symbols=true"] if self.config.debug_symbols else []), f"--iree-hal-dump-executable-sources-to={dumps_dir}", f"--iree-hal-dump-executable-intermediates-to={dumps_dir}", f"--iree-hal-dump-executable-binaries-to={dumps_dir}", @@ -378,7 +381,7 @@ def _dump_full_pass_traces(self, torch_input_path: Path, mlir_dir: Path, passes_ "--iree-transformation-pipeline", "--mlir-print-debuginfo", *self._pass_print_flags(), - "--mlir-elide-elementsattrs-if-larger=8", + *self._elide_flags(), "-o", str(final_vm_output), ] @@ -413,9 +416,9 @@ def _summarize_dispatches(self, output_dir: Path, manifest: dict) -> None: EXAMPLES = { - "matmul": "examples.matmul", - "linear_relu": "examples.linear_relu", - "mini_transformer": "examples.mini_transformer", + "matmul": "compilerlens.examples.matmul", + "linear_relu": "compilerlens.examples.linear_relu", + "mini_transformer": "compilerlens.examples.mini_transformer", } @@ -435,8 +438,8 @@ def _load_hf_model(model_id: str, seq_len: int): Kept out of EXAMPLES because these are not enumerable: any Hub id works, so it is a separate flag rather than a registry entry. """ - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap return wrap(detect(model_id, seq_len=seq_len)) diff --git a/compilerlens/backend/measure/__init__.py b/compilerlens/backend/measure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/measure/bench.py b/compilerlens/backend/measure/bench.py similarity index 96% rename from backend/measure/bench.py rename to compilerlens/backend/measure/bench.py index dbfb61c..baca4f8 100644 --- a/backend/measure/bench.py +++ b/compilerlens/backend/measure/bench.py @@ -14,6 +14,7 @@ import json import shutil +from compilerlens.toolchain import find_tool import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -62,7 +63,7 @@ class BenchmarkError(RuntimeError): def _tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise BenchmarkError( f"'{name}' not found on PATH. Activate the project venv before benchmarking." @@ -80,6 +81,7 @@ def benchmark_module( function: str = "main", repetitions: int = MIN_REPETITIONS, device: str = "local-task", + workers: int | None = None, ) -> BenchResult: """Time one entry point of a compiled .vmfb. @@ -97,9 +99,10 @@ def benchmark_module( f"--benchmark_repetitions={repetitions}", "--benchmark_format=json", ] + if workers is not None: cmd.append(f"--task_topology_max_group_count={workers}") cmd += [f"--input={spec}" for spec in inputs] - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) if result.returncode != 0 or not result.stdout.strip(): raise BenchmarkError( f"iree-benchmark-module failed (exit {result.returncode}): " diff --git a/backend/measure/cli.py b/compilerlens/backend/measure/cli.py similarity index 92% rename from backend/measure/cli.py rename to compilerlens/backend/measure/cli.py index d88977f..d612260 100644 --- a/backend/measure/cli.py +++ b/compilerlens/backend/measure/cli.py @@ -16,13 +16,13 @@ import sys from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) +from compilerlens.storage import workspace -from backend.measure.compare import run_experiment # noqa: E402 -from backend.measure.variants import DIMENSIONS # noqa: E402 -from models.detect import UnsupportedModelError # noqa: E402 +REPO_ROOT = workspace() + +from compilerlens.backend.measure.compare import run_experiment # noqa: E402 +from compilerlens.backend.measure.variants import DIMENSIONS # noqa: E402 +from compilerlens.models.detect import UnsupportedModelError # noqa: E402 def _print_dimensions() -> None: diff --git a/backend/measure/compare.py b/compilerlens/backend/measure/compare.py similarity index 92% rename from backend/measure/compare.py rename to compilerlens/backend/measure/compare.py index 99caf35..bff430b 100644 --- a/backend/measure/compare.py +++ b/compilerlens/backend/measure/compare.py @@ -11,6 +11,7 @@ import re import shutil +from compilerlens.toolchain import find_tool import subprocess import time from dataclasses import dataclass, field @@ -18,8 +19,8 @@ import iree.turbine.aot as aot -from backend.measure.bench import BenchResult, benchmark_module, compare_timings -from backend.measure.variants import Dimension, Variant +from compilerlens.backend.measure.bench import BenchResult, benchmark_module, compare_timings +from compilerlens.backend.measure.variants import Dimension, Variant # Signals we read out of the compiled output to explain a timing difference. Each is a fact # the compiler emitted, not an interpretation of one. @@ -72,24 +73,17 @@ def as_dict(self) -> dict: def _tool(name: str) -> str: - path = shutil.which(name) + path = find_tool(name) if path is None: raise RuntimeError(f"'{name}' not found on PATH; activate the venv.") return path -def _input_specs(example_inputs: tuple) -> list: - """iree-run-module input descriptors for the traced example inputs. - - Values are fixed (1 for ids, 0 for the additive mask) so a benchmark is reproducible and - so the mask means "attend everywhere" rather than something arbitrary. - """ - specs = [] - for index, tensor in enumerate(example_inputs): - shape = "x".join(str(d) for d in tensor.shape) - dtype = {"torch.int64": "i64", "torch.float32": "f32"}.get(str(tensor.dtype), "f32") - specs.append(f"{shape}x{dtype}={1 if index == 0 else 0}") - return specs +def _input_specs(example_inputs: tuple, output_dir: Path) -> list: + """Persist actual tensors, including causal masks, for IREE's NPY reader.""" + from compilerlens.services import save_inputs + records = save_inputs(example_inputs, output_dir) + return ['@' + str((output_dir / r['path']).resolve()) for r in records] def _read_signals(output_dir: Path) -> dict: @@ -238,8 +232,8 @@ def run_experiment( benchmark: bool = True, ) -> dict: """Compile and time every variant of `dimension`, then compare them pairwise.""" - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap detected = detect(model_id, seq_len=seq_len) module, example_inputs, model_info = wrap(detected) @@ -251,7 +245,7 @@ def run_experiment( # program with different flags. mlir_path = output_root / "input.mlir" mlir_path.write_text(str(aot.export(module, *example_inputs).mlir_module)) - input_specs = _input_specs(example_inputs) + input_specs = _input_specs(example_inputs, output_root) runs = [] for variant in dimension.variants: diff --git a/backend/measure/variants.py b/compilerlens/backend/measure/variants.py similarity index 100% rename from backend/measure/variants.py rename to compilerlens/backend/measure/variants.py diff --git a/compilerlens/benchmark.py b/compilerlens/benchmark.py new file mode 100644 index 0000000..17a8dc2 --- /dev/null +++ b/compilerlens/benchmark.py @@ -0,0 +1,24 @@ +"""Benchmark saved executables against their captured input values.""" +import json +from pathlib import Path + +from .storage import load_run, digest, atomic_json + + +def benchmark_run(run, *, workers=8, repetitions=10): + from .backend.measure.bench import benchmark_module + root, artifact, _ = load_run(run) + index = root / 'inputs/index.json' + if not index.is_file(): raise ValueError('This run has no saved inputs. Recompile to benchmark exact tensors.') + inputs = [] + for record in json.loads(index.read_text()): + path = (root / record['path']).resolve() + if not path.is_relative_to(root) or not path.is_file() or digest(path) != record['sha256']: + raise ValueError('Captured input is missing or its hash changed.') + inputs.append('@' + str(path)) + executables = list(root.glob('*.vmfb')) + if len(executables) != 1: raise ValueError('Expected exactly one saved .vmfb executable.') + result = benchmark_module(executables[0], inputs, repetitions=repetitions, workers=workers).as_dict() + result.update(workers=workers, input_values='exact saved tensors', compilation_id=artifact['compilation_id']) + atomic_json(root / 'benchmark.json', result) + return result diff --git a/compilerlens/cli.py b/compilerlens/cli.py new file mode 100644 index 0000000..e0e79c3 --- /dev/null +++ b/compilerlens/cli.py @@ -0,0 +1,211 @@ +"""CompilerLens command line. Diagnostics go to stderr; JSON stdout stays clean.""" +from __future__ import annotations + +import argparse +import contextlib +import json +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +from . import __version__ +from .storage import load_run + + +def positive(value): + number = int(value) + if number <= 0: raise argparse.ArgumentTypeError('must be positive') + return number + + +def line_range(value): + try: a, b = map(int, value.split(':')) + except ValueError: raise argparse.ArgumentTypeError('expected START:END') from None + if a < 1 or b < a: raise argparse.ArgumentTypeError('expected 1 <= START <= END') + return a, b + + +def parser(): + p = argparse.ArgumentParser(prog='compilerlens', description='Inspect model compilation from Torch to MLIR, LLVM and assembly.') + p.add_argument('--version', action='version', version=f'CompilerLens {__version__}') + sub = p.add_subparsers(dest='command', required=True) + compile = sub.add_parser('compile', help='Compile and capture a model into a fresh run') + compile.add_argument('model_id', nargs='?') + inputs = compile.add_mutually_exclusive_group() + inputs.add_argument('--example', choices=('matmul', 'linear_relu', 'mini_transformer')) + inputs.add_argument('--python', help='Local file.py:factory returning (module, example_inputs)') + compile.add_argument('--seq-len', type=positive, help='HF static sequence length (default: 16)') + compile.add_argument('--revision', help='HF commit or reference; resolved commit is saved') + compile.add_argument('--offline', action='store_true') + compile.add_argument('--seed', type=int, default=0) + compile.add_argument('--cpu', default='host') + compile.add_argument('--capture', choices=('standard', 'full'), default='standard') + compile.add_argument('--lineage', choices=('auto', 'required', 'off'), default='auto') + compile.add_argument('--out', type=Path) + imported = sub.add_parser('import', help='Analyze existing mlir/llvm/passes dumps') + imported.add_argument('source', type=Path) + imported.add_argument('--out', type=Path) + imported.add_argument('--lineage', choices=('auto', 'required', 'off'), default='auto') + inspect = sub.add_parser('inspect', help='Inspect saved results without recompiling') + inspect.add_argument('run', type=Path) + inspect.add_argument('--show', choices=('summary', 'architecture', 'stages', 'evidence', 'ops', 'ir', 'objects'), default='summary') + inspect.add_argument('--stage') + inspect.add_argument('--module') + inspect.add_argument('--lines', type=line_range) + trace = sub.add_parser('trace', help='Query compiler-recorded source attribution') + trace.add_argument('run', type=Path) + selectors = trace.add_mutually_exclusive_group(required=True) + for name in ('op', 'module', 'source', 'from-stage', 'object'): selectors.add_argument('--' + name) + trace.add_argument('--line', type=positive, help='1-based display line for reverse lookup') + trace.add_argument('--section', help='Object section, e.g. .text') + trace.add_argument('--address', help='Section-relative byte offset, decimal or 0xhex') + trace.add_argument('--to', choices=('llvm', 'asm', 'all'), default='all') + diff = sub.add_parser('diff', help='Compare stages on the same compilation track') + diff.add_argument('run', type=Path) + diff.add_argument('--from', dest='before', required=True) + diff.add_argument('--to', dest='after', required=True) + diff.add_argument('--mode', choices=('text', 'semantic'), default='text') + view = sub.add_parser('view', help='Serve this run in the existing bundled webpage') + view.add_argument('run', type=Path) + view.add_argument('--port', type=positive, default=8000) + view.add_argument('--open', action='store_true', dest='open_browser') + doctor = sub.add_parser('doctor', help='Check installed compiler and native components') + bench = sub.add_parser('bench', help='Benchmark using the exact captured input tensors') + bench.add_argument('run', type=Path) + bench.add_argument('--workers', type=positive, default=8) + bench.add_argument('--repetitions', type=positive, default=10) + for command in (compile, imported, inspect, trace, diff, doctor, bench): + command.add_argument('--format', choices=('text', 'json'), default='text') + return p + + +def doctor_report(): + from .provenance import native_binary + from .services import versions + from .toolchain import find_tool + native = native_binary() + report = {'versions': versions(), 'native': {'available': bool(native)}, + 'tools': {name: find_tool(name) for name in ('iree-compile', 'iree-opt', 'iree-benchmark-module')}, + 'web_assets': (Path(__file__).parent / '_web/index.html').is_file(), + 'supported_target': 'Linux x86-64 CPU', 'host_supported': sys.platform == 'linux' and platform.machine() == 'x86_64'} + if native: + try: + result = subprocess.run([str(native), '--version'], capture_output=True, text=True, timeout=10) + report['native'].update(path=str(native), version=result.stdout.strip(), available=result.returncode == 0, error=result.stderr.strip()) + except (OSError, subprocess.TimeoutExpired) as exc: report['native'].update(available=False, error=str(exc)) + report['ready'] = report['host_supported'] and report['native']['available'] and report['web_assets'] and all(report['tools'].values()) and all(v != 'not installed' for k, v in report['versions'].items() if k != 'compilerlens') + return report + + +def render(value, *, json_output=False, kind=None): + if json_output: + print(json.dumps(value, indent=2, allow_nan=False)) + return + from rich.console import Console + from rich.syntax import Syntax + from rich.table import Table + from rich.tree import Tree + console = Console(highlight=False) + if isinstance(value, str): console.print(value, markup=False); return + if kind == 'ir': + console.print(f"{value['stage']} · lines {value['start_line']}–{value['end_line']}", markup=False) + console.print(Syntax(value['text'], {'llvm': 'llvm', 'asm': 'asm'}.get(value['language'], value['language']), line_numbers=True, start_line=value['start_line'], background_color='default')) + return + if kind == 'summary': + console.print(f"CompilerLens · {value['compilation_id']}", style='bold', markup=False) + console.print(f"{value['stage_count']} stages · {value['evidence_count']} evidence items · provenance: {value['provenance']}") + console.print('Target: ' + json.dumps(value['target']), markup=False) + if value['coverage']: + render([{'Stage': name, 'Ops': c['instructions'], 'Debug': c.get('debug_locations', '—'), + 'Dispatch': c.get('dispatch_resolved', '—'), 'Source': c['source_associated'], + 'Module': c.get('module_associated', '—')} for name, c in value['coverage'].items()]) + for note in value['notes']: console.print('• ' + note, markup=False) + return + if kind == 'architecture': + root = Tree('Model architecture') + nodes = {n['id']: n for n in value.get('nodes', [])} + trees = {} + for n in sorted(nodes.values(), key=lambda n: n.get('depth', 0)): + parent = trees.get(n.get('parent_id'), root) + trees[n['id']] = parent.add(f"{n['path']} ({n['type']}) · {n.get('parameter_count', 0):,} parameters · {n.get('mapping', 'unknown')}") + console.print(root) + console.print(value.get('mapping_note', ''), markup=False) + return + if kind == 'trace' and 'matches' in value: + console.print(value['relationship'], markup=False) + console.print(f"{len(value['matches'])} matching instructions; {len(value['origins'])} source locations") + rows = [{'stage': r['stage'], 'line': r['line'], 'id': r['id'], 'instruction': r.get('text', r.get('opcode', '')), + 'origins': '; '.join(f"torch-input:{o['line']}:{o['column']}" for o in r.get('origins', [])), + 'via': '; '.join(f"{f['file']}:{f['line']}:{f.get('column', 0)}" for f in r.get('frames', []))} for r in value['matches']] + render(rows) + for note in value['notes']: console.print(note, markup=False) + return + if isinstance(value, list) and value and all(isinstance(row, dict) for row in value): + keys = list(value[0]) + if kind == 'ops': keys = [k for k in ('id', 'line', 'name', 'text', 'origins') if k in value[0]] + table = Table(*keys, expand=False) + for row in value: + table.add_row(*(json.dumps(row.get(k), ensure_ascii=False) if isinstance(row.get(k), (dict, list)) else str(row.get(k, '')) for k in keys)) + console.print(table) + else: + console.print_json(json.dumps(value, ensure_ascii=False)) + + +def main(argv=None): + args = parser().parse_args(argv) + try: + command, fmt = args.command, getattr(args, 'format', 'text') + kind = None + if command in ('compile', 'import'): + from .services import capture, import_dump + kwargs = vars(args).copy() + kwargs.pop('command'); kwargs.pop('format') + # Libraries sometimes print progress to stdout; reserve stdout for the result. + with contextlib.redirect_stdout(sys.stderr): + root = (capture if command == 'compile' else import_dump)(**kwargs, progress=lambda label: print(label, file=sys.stderr)) + value = {'run': str(root), 'status': 'complete', 'artifact': str(root / 'artifact.json')} + elif command == 'doctor': value = doctor_report() + elif command == 'view': + from .server import serve + serve(args.run, port=args.port, open_browser=args.open_browser) + return 0 + elif command == 'bench': + from .benchmark import benchmark_run + value = benchmark_run(args.run, workers=args.workers, repetitions=args.repetitions) + else: + from .queries import inspect_report, trace_report, diff_report + root, artifact, provenance = load_run(args.run) + if command == 'inspect': + if args.stage and args.show not in ('evidence', 'ops', 'ir'): raise ValueError('--stage applies to --show evidence, ops or ir.') + if args.lines and args.show not in ('ops', 'ir'): raise ValueError('--lines applies to --show ops or ir.') + if args.module and args.show not in ('ops', 'architecture'): raise ValueError('--module applies to --show ops or architecture.') + if args.show == 'objects': + from .provenance import object_sections + value = object_sections(root) + else: + value = inspect_report(artifact, provenance, show=args.show, stage=args.stage, module=args.module, lines=args.lines) + kind = args.show + elif command == 'trace': + if args.object: + if not args.section or args.address is None or args.line is not None: raise ValueError('--object requires --section and --address (not --line).') + from .provenance import object_lookup + value = object_lookup(root, args.object, args.section, args.address) + else: + if args.section or args.address is not None: raise ValueError('--section and --address require --object.') + value = trace_report(artifact, provenance, op=args.op, module=args.module, source=args.source, from_stage=args.from_stage, line=args.line, to=args.to) + kind = 'trace' + else: value = diff_report(artifact, provenance, args.before, args.after, args.mode) + render(value, json_output=fmt == 'json', kind=kind) + return 0 if command != 'doctor' or value['ready'] else 1 + except (OSError, ValueError, RuntimeError, ImportError, subprocess.SubprocessError) as exc: + print(f'compilerlens: {exc}', file=sys.stderr) + return 1 + except KeyboardInterrupt: + print('compilerlens: interrupted; captured diagnostics are retained.', file=sys.stderr) + return 130 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/compilerlens/examples/__init__.py b/compilerlens/examples/__init__.py new file mode 100644 index 0000000..79ec2f4 --- /dev/null +++ b/compilerlens/examples/__init__.py @@ -0,0 +1 @@ +"""Small offline compiler workloads.""" diff --git a/compilerlens/examples/linear_relu.py b/compilerlens/examples/linear_relu.py new file mode 100644 index 0000000..f671c6f --- /dev/null +++ b/compilerlens/examples/linear_relu.py @@ -0,0 +1,19 @@ +import torch + + +class LinearReluModule(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(128, 128)) + self.bias = torch.nn.Parameter(torch.randn(128)) + + def forward(self, x): + return torch.relu(x @ self.weight + self.bias) + + +def build_module(): + return LinearReluModule() + + +def example_inputs(): + return (torch.randn(128, 128),) diff --git a/compilerlens/examples/matmul.py b/compilerlens/examples/matmul.py new file mode 100644 index 0000000..9c8e89d --- /dev/null +++ b/compilerlens/examples/matmul.py @@ -0,0 +1,14 @@ +import torch + + +class MatmulModule(torch.nn.Module): + def forward(self, a, b): + return torch.matmul(a, b) + + +def build_module(): + return MatmulModule() + + +def example_inputs(): + return (torch.randn(128, 128), torch.randn(128, 128)) diff --git a/compilerlens/examples/mini_transformer.py b/compilerlens/examples/mini_transformer.py new file mode 100644 index 0000000..b8a2205 --- /dev/null +++ b/compilerlens/examples/mini_transformer.py @@ -0,0 +1,30 @@ +import torch + + +class MiniTransformerBlock(torch.nn.Module): + def __init__(self, d_model=64, n_heads=4, d_ff=128): + super().__init__() + self.qkv = torch.nn.Linear(d_model, d_model * 3) + self.out_proj = torch.nn.Linear(d_model, d_model) + self.norm1 = torch.nn.LayerNorm(d_model) + self.ff1 = torch.nn.Linear(d_model, d_ff) + self.ff2 = torch.nn.Linear(d_ff, d_model) + self.norm2 = torch.nn.LayerNorm(d_model) + self.n_heads = n_heads + + def forward(self, x): + qkv = self.qkv(x) + q, k, v = qkv.chunk(3, dim=-1) + attn_scores = torch.softmax((q @ k.transpose(-2, -1)) / (q.shape[-1] ** 0.5), dim=-1) + attn_out = attn_scores @ v + x = self.norm1(x + self.out_proj(attn_out)) + ff_out = self.ff2(torch.relu(self.ff1(x))) + return self.norm2(x + ff_out) + + +def build_module(): + return MiniTransformerBlock() + + +def example_inputs(): + return (torch.randn(16, 64),) diff --git a/compilerlens/ingest/__init__.py b/compilerlens/ingest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ingest/architecture.py b/compilerlens/ingest/architecture.py similarity index 100% rename from ingest/architecture.py rename to compilerlens/ingest/architecture.py diff --git a/ingest/build.py b/compilerlens/ingest/build.py similarity index 95% rename from ingest/build.py rename to compilerlens/ingest/build.py index 2135cc9..08fd050 100644 --- a/ingest/build.py +++ b/compilerlens/ingest/build.py @@ -459,7 +459,7 @@ def add(kind: str, label: str, value: str, stage: Stage, detail: str, status: st return evidence, target -def build_artifact(workload: WorkloadSpec, root: Path | None = None) -> Artifact: +def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, analysis: dict | None = None) -> Artifact: """Build one workload's artifact. `root` overrides `workload.dump_root` -- used by Stage 5 to point at a fresh temp @@ -472,24 +472,40 @@ def build_artifact(workload: WorkloadSpec, root: Path | None = None) -> Artifact if not stages: raise SystemExit(f"no stages found under {dump_root}") + source_sets = None + if analysis is not None: + from compilerlens.provenance import enrich_stages + from compilerlens.storage import atomic_json + sidecar, source_sets = enrich_stages(dump_root, stages, analysis) + atomic_json(dump_root / "provenance.json", sidecar) + # Evidence, diffs and lineage all read the full op index, so trimming happens only after # they have been derived. Lineage in particular *must* precede it: trimming clears the op # lists for the LLVM, assembly and per-pass stages, which are exactly the stages a hover # most wants to highlight. evidence, target = _build_evidence(stages) diffs = _build_diffs(stages) - lineage = build_lineage(stages) + lineage = build_lineage(stages, source_sets=source_sets) architecture = architecture_builder.build_architecture(dump_root, workload, stages, lineage) + if analysis is not None: + mapped_lines = {line for node in architecture.get('nodes', []) if node.get('mapping') == 'exact' + for line in node.get('source_lines', [])} + for entry in sidecar['stages'].values(): + entry['coverage']['module_associated'] = sum(any(o['line'] in mapped_lines for o in r['origins']) for r in entry['records']) + atomic_json(dump_root / 'provenance.json', sidecar) located = sum(1 for s in stages for op in s.ops if op.source_loc) trim_note = _trim_op_index(stages) source_stage = _find_stage(stages, "pytorch-source") notes = [ - "Operation counts come from a heuristic textual parser, not a real MLIR parser. " - "The IR itself is displayed verbatim and is always authoritative.", + "MLIR/assembly operation counts use textual parsers. Native LLVM counts use LLVM instructions. " + "LLVM display views add ID comments; original dumps are retained.", ] notes.extend(stage_notes) + if analysis is not None: + notes.append("Provenance: " + analysis["status"] + ". Native IDs are snapshot-local; assembly links use emitted .file/.loc associations. Source attribution is not instruction identity.") + notes.extend(analysis["notes"]) if trim_note: notes.append(trim_note) if located: diff --git a/ingest/lineage.py b/compilerlens/ingest/lineage.py similarity index 95% rename from ingest/lineage.py rename to compilerlens/ingest/lineage.py index c1cac84..ea791b4 100644 --- a/ingest/lineage.py +++ b/compilerlens/ingest/lineage.py @@ -227,7 +227,7 @@ def pass_count(stage_id: str) -> int: return hops -def build_lineage(stages: list) -> dict: +def build_lineage(stages: list, *, source_sets: dict | None = None) -> dict: """Index from anchor-stage line number to every operation derived from it. Shape, keyed by line number as a string so it survives JSON: @@ -264,16 +264,20 @@ def build_lineage(stages: list) -> dict: op_names: dict = defaultdict(lambda: defaultdict(int)) for stage in stages: + if source_sets is not None and stage is anchor: + continue # Anchor membership is added once below. for op in stage.ops: - if not op.source_loc: - continue - parsed = _split_loc(op.source_loc) - if parsed is None: - continue - source_line = parsed[0] - hits[source_line][stage.id].append(op.line) - stage_names[source_line][stage.id][op.name] += 1 - op_names[source_line][op.name] += 1 + locations = source_sets.get(op.id, []) if source_sets is not None else ([op.source_loc] if op.source_loc else []) + seen = set() + for location in locations: + parsed = _split_loc(location) + if parsed is None or parsed[0] in seen: + continue + source_line = parsed[0] + seen.add(source_line) + hits[source_line][stage.id].append(op.line) + stage_names[source_line][stage.id][op.name] += 1 + op_names[source_line][op.name] += 1 # The anchor stage's own operations carry no source_loc -- they *are* the source, so nothing # points back at them. Without this, the index has no entry for the anchor stage itself and diff --git a/ingest/llvm_parser.py b/compilerlens/ingest/llvm_parser.py similarity index 100% rename from ingest/llvm_parser.py rename to compilerlens/ingest/llvm_parser.py diff --git a/ingest/mlir_loc.py b/compilerlens/ingest/mlir_loc.py similarity index 85% rename from ingest/mlir_loc.py rename to compilerlens/ingest/mlir_loc.py index c0b30b1..4d849c3 100644 --- a/ingest/mlir_loc.py +++ b/compilerlens/ingest/mlir_loc.py @@ -165,3 +165,28 @@ def strip_locations(text: str) -> str: continue out.append(line) return '\n'.join(out) + + +def source_location_sets_by_line(text: str) -> dict[int, list[str]]: + """All explicit file/line/column leaves in a location, including fused sets.""" + aliases = collect_aliases(text) + output = {} + leaf = re.compile(r'(? WorkloadSpec: "PyTorch Source", PHASE_FRONTEND, "python", - f"{model_id}: a {kind}, {param_text}, exported through iree-turbine's AOT " - f"interface with a sequence length of {seq_len}. The attention mask is built " - f"as a 4D float tensor -- see the comment in the source for why that is " - f"required rather than incidental.", + f"{model_id}: {param_text}. The source used to create this captured workload. " + "Static tensor shapes and actual input values are recorded in the run.", track="source", ) ) @@ -149,41 +147,23 @@ def spec_from_dump_dir(dump_root: Path, model_info: dict) -> WorkloadSpec: stages.append(StageSpec(f"mlir/{filename}", name, title, phase, "mlir", description)) kernels = _dispatch_kernels(llvm_dir) - if kernels: - chosen = kernels[0] - stages.append( - StageSpec( - f"llvm/{chosen.name}", - "dispatch-kernel", - "Dispatch Kernel (MLIR)", - PHASE_LLVM, - "mlir", - f"The device kernel track begins here. This model compiled to " - f"{len(kernels)} dispatch kernels; shown is {_dispatch_scope(chosen)}, the " - f"largest. It branches off the Executable Targets phase rather than " - f"following the VM stage before it.", - track="device", - ) - ) + for i, kernel in enumerate(kernels): + stages.append(StageSpec( + f"llvm/{kernel.name}", "dispatch-kernel" if i == 0 else f"dispatch-kernel-{i}", + f"Dispatch Kernel: {_dispatch_scope(kernel)}", PHASE_LLVM, "mlir", + f"Captured device kernel {i+1} of {len(kernels)}. Separate from the host module track.", + track=f"kernel:{kernel.stem}")) for pattern, name, title, phase, language, description in _LLVM_STAGES: matches = sorted(llvm_dir.glob(pattern)) if llvm_dir.is_dir() else [] - if not matches: - continue - # One linked module covers every dispatch, so take the largest match and say so. - chosen_llvm = max(matches, key=lambda p: p.stat().st_size) - note = "" if len(matches) == 1 else f" ({len(matches)} such files were emitted; this is the largest.)" - stages.append( - StageSpec( - f"llvm/{chosen_llvm.name}", - name, - title, - phase, - language, - description + note, - track="device", - ) - ) + for i, path in enumerate(matches): + # The same module's checkpoints compare together; unrelated modules do not. + module = path.name + for suffix in ('.codegen.ll', '.linked.ll', '.optimized.ll', '.s'): + if module.endswith(suffix): module = module[:-len(suffix)]; break + stages.append(StageSpec(f"llvm/{path.name}", name if i == 0 else f"{name}-{i}", + title if len(matches) == 1 else f"{title}: {module}", + phase, language, description, track=f"device:{module}")) pass_tracks: list[PassTrackSpec] = [] step_a = passes_dir / "passes_stepA_torch_to_iree.txt" @@ -220,7 +200,7 @@ def spec_from_dump_dir(dump_root: Path, model_info: dict) -> WorkloadSpec: return WorkloadSpec( id=dump_root.name, title=model_id, - description=f"{model_id} -- {kind}, {param_text}, compiled from the HuggingFace Hub.", + description=f"{model_id} -- {param_text}; captured compiler stages.", dump_root=str(dump_root), source_entry=model_id, stages=stages, diff --git a/ingest/workloads/linear_relu.py b/compilerlens/ingest/workloads/linear_relu.py similarity index 100% rename from ingest/workloads/linear_relu.py rename to compilerlens/ingest/workloads/linear_relu.py diff --git a/ingest/workloads/matmul.py b/compilerlens/ingest/workloads/matmul.py similarity index 100% rename from ingest/workloads/matmul.py rename to compilerlens/ingest/workloads/matmul.py diff --git a/compilerlens/models/__init__.py b/compilerlens/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/architecture.py b/compilerlens/models/architecture.py similarity index 98% rename from models/architecture.py rename to compilerlens/models/architecture.py index 2f88546..21c30aa 100644 --- a/models/architecture.py +++ b/compilerlens/models/architecture.py @@ -15,7 +15,7 @@ import torch -from ingest.mlir_parser import parse_operations +from compilerlens.ingest.mlir_parser import parse_operations def export_program(module: torch.nn.Module, example_args: tuple) -> torch.export.ExportedProgram: @@ -325,8 +325,8 @@ def main() -> None: raise SystemExit(f"missing {info_path}") from iree.turbine import aot - from models.detect import detect - from models.hf_wrapper import wrap + from compilerlens.models.detect import detect + from compilerlens.models.hf_wrapper import wrap info = json.loads(info_path.read_text()) detected = detect( diff --git a/models/detect.py b/compilerlens/models/detect.py similarity index 86% rename from models/detect.py rename to compilerlens/models/detect.py index a2fe497..a32c353 100644 --- a/models/detect.py +++ b/compilerlens/models/detect.py @@ -12,6 +12,7 @@ from __future__ import annotations import json +import re from dataclasses import dataclass from pathlib import Path @@ -53,6 +54,8 @@ class DetectedModel: model_type: str # for the record, e.g. "bert"; "unknown" when only sniffing worked detected_via: str # "autoconfig" | "config-keys" -- reported so the user sees which path ran + offline: bool = False + @property def slug(self) -> str: """Filesystem/URL-safe id: prajjwal1/bert-tiny -> prajjwal1_bert-tiny.""" @@ -70,7 +73,7 @@ def load_model(self, *, dtype=None): from transformers import AutoConfig, AutoModel, AutoModelForCausalLM model_class = AutoModelForCausalLM if self.causal else AutoModel - load_kwargs = {"revision": self.revision} + load_kwargs = {"revision": self.revision, "local_files_only": self.offline, "trust_remote_code": False} if dtype is not None: # Some checkpoints are stored as bfloat16. Turbine's current FX importer lifts # constants through NumPy, which cannot consume a torch.bfloat16 tensor. Loading @@ -92,7 +95,7 @@ def _config_overrides(self) -> dict: back to the family default, which is exactly what a config lacking model_type is relying on anyway. """ - raw = _raw_config(self.model_id, self.revision) + raw = _raw_config(self.model_id, self.revision, offline=self.offline) return {k: v for k, v in raw.items() if k not in {"model_type", "architectures"}} @@ -148,27 +151,31 @@ def _valid(sha: str) -> bool: return None -def _resolve_revision(model_id: str, revision: str | None) -> str: - if revision: - return revision +def _resolve_revision(model_id: str, revision: str | None, *, offline=False) -> str: + ref = revision or 'main' + if re.fullmatch(r'[0-9a-fA-F]{40}', ref): + return ref + if not offline: + try: + return HfApi().model_info(model_id, revision=ref).sha + except Exception: + pass try: - return HfApi().model_info(model_id).sha - except Exception as exc: # network, offline mode, 404, gated repo, rate limit - cached = _cached_revision(model_id) - if cached: - return cached - raise UnsupportedModelError( - f"could not resolve a revision for '{model_id}': {exc}\n" - f" If the model is cached, pass --revision to skip the Hub lookup." - ) from exc + path = Path(hf_hub_download(model_id, 'config.json', revision=ref, local_files_only=True)) + # Hub returns the snapshot path; resolving the symlink would lose its commit. + sha = path.parent.name + if re.fullmatch(r'[0-9a-fA-F]{40}', sha): return sha + except Exception: + pass + raise UnsupportedModelError(f"Could not resolve '{model_id}' revision '{ref}' to a commit. Download that revision first or pass a cached commit SHA.") -def _raw_config(model_id: str, revision: str) -> dict: +def _raw_config(model_id: str, revision: str, *, offline=False) -> dict: try: - path = hf_hub_download(model_id, "config.json", revision=revision) + path = hf_hub_download(model_id, "config.json", revision=revision, local_files_only=offline) except Exception as exc: - raise UnsupportedModelError(f"'{model_id}' has no downloadable config.json: {exc}") from exc - return json.loads(open(path).read()) + raise UnsupportedModelError(f"'{model_id}' has no available config.json: {exc}") from exc + return json.loads(Path(path).read_text()) def _causal_from(model_type: str, architectures: list[str] | None, is_decoder) -> bool: @@ -180,14 +187,14 @@ def _causal_from(model_type: str, architectures: list[str] | None, is_decoder) - return bool(is_decoder) -def detect(model_id: str, revision: str | None = None, seq_len: int = 32) -> DetectedModel: +def detect(model_id: str, revision: str | None = None, seq_len: int = 32, *, offline: bool = False) -> DetectedModel: """Determine how to load and trace `model_id`. Tries transformers' own AutoConfig first, then falls back to reading config.json directly. Raises UnsupportedModelError if neither identifies the model. """ - resolved = _resolve_revision(model_id, revision) - config = _raw_config(model_id, resolved) + resolved = _resolve_revision(model_id, revision, offline=offline) + config = _raw_config(model_id, resolved, offline=offline) model_type = config.get("model_type") architectures = config.get("architectures") @@ -199,7 +206,7 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32) -> Det try: from transformers import AutoConfig - parsed = AutoConfig.from_pretrained(model_id, revision=resolved) + parsed = AutoConfig.from_pretrained(model_id, revision=resolved, local_files_only=offline, trust_remote_code=False) model_type = parsed.model_type architectures = getattr(parsed, "architectures", None) or architectures detected_via = "autoconfig" @@ -248,4 +255,5 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32) -> Det output_attr="logits" if causal else "last_hidden_state", model_type=model_type or "unknown", detected_via=detected_via, + offline=offline, ) diff --git a/models/hf_wrapper.py b/compilerlens/models/hf_wrapper.py similarity index 99% rename from models/hf_wrapper.py rename to compilerlens/models/hf_wrapper.py index 66d1580..a2bc765 100644 --- a/models/hf_wrapper.py +++ b/compilerlens/models/hf_wrapper.py @@ -25,7 +25,7 @@ import torch -from models.detect import DetectedModel +from compilerlens.models.detect import DetectedModel class HFWrapper(torch.nn.Module): diff --git a/models/prefetch.py b/compilerlens/models/prefetch.py similarity index 94% rename from models/prefetch.py rename to compilerlens/models/prefetch.py index 7bb97df..ce91109 100644 --- a/models/prefetch.py +++ b/compilerlens/models/prefetch.py @@ -10,7 +10,7 @@ from huggingface_hub import snapshot_download -from models.detect import detect +from compilerlens.models.detect import detect def prefetch(model_id: str) -> None: diff --git a/compilerlens/provenance.py b/compilerlens/provenance.py new file mode 100644 index 0000000..851621d --- /dev/null +++ b/compilerlens/provenance.py @@ -0,0 +1,292 @@ +"""Native LLVM reports and conservative source/assembly joins. + +Def-use edges and source attribution are deliberately separate relationships. +""" +from __future__ import annotations + +import json +import os +import re +import shlex +import shutil +import subprocess +from pathlib import Path + +from .storage import atomic_json, digest +from .ingest.mlir_loc import source_location_sets_by_line +from .ingest.llvm_parser import parse_asm, _classify_ll +from .ingest.schema import Operation + +PROTOCOL = 1 +_LOCATION = re.compile(r'^(.*):(\d+):(\d+)$') + + +def native_binary() -> Path | None: + override = os.environ.get('COMPILERLENS_NATIVE') + if override: + path = Path(override).expanduser().resolve() + return path if path.is_file() else None + package = Path(__file__).resolve().parent + for path in (package / '_bin' / 'compilerlens-native', package.parent / 'build/native/compilerlens-native'): + if path.is_file() and os.access(path, os.X_OK): + return path + found = shutil.which('compilerlens-native') + return Path(found) if found else None + + +class SourceIndex: + def __init__(self, root: Path): + self.root = root.resolve() + self.files = {} + self.names = {} + self.aliases = {} + self.anchor = self.root / 'mlir/ir_00_torch_input.mlir' + self.anchor_lines = len(self.anchor.read_text().splitlines()) if self.anchor.exists() else 0 + self.anchor_aliases = {str(self.anchor), str(self.root / '_full' / self.anchor.name), self.anchor.name, + 'mlir/' + self.anchor.name, '_full/' + self.anchor.name} + manifest_path = self.root / 'manifest.json' + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text()) + original = manifest.get('files', {}).get(self.anchor.name) + if original: + self.anchor_aliases.add(str(original)) + original_path = Path(original) + if original_path.parent.name == 'mlir': + self.anchor_aliases.add(str(original_path.parent.parent / '_full' / original_path.name)) + self.legacy_anchor = not manifest_path.is_file() + + for path in sorted((self.root / 'llvm').glob('*dispatch_*.mlir')): + self.files[str(path)] = source_location_sets_by_line(path.read_text()) + self.names.setdefault(path.name, []).append(str(path)) + if match := re.search(r'(dispatch_\d+)\.mlir$', path.name): + self.aliases.setdefault(match[1], []).append(str(path)) + + def file_for(self, filename: str, directory: str = '') -> str | None: + full = str(Path(directory) / filename) if directory else filename + if full in self.files: + return full + # Relocated artifacts: only a unique basename in this compilation can match. + name = Path(filename.replace('\\', '/')).name + candidates = self.names.get(name, []) + if not candidates and (match := re.search(r'(dispatch_\d+)\.mlir$', name)): + candidates = self.aliases.get(match[1], []) + return candidates[0] if len(candidates) == 1 else None + + def canonical(self, location: str) -> dict | None: + match = _LOCATION.match(location) + if not match: + return None + filename, line, column = match[1], int(match[2]), int(match[3]) + # Only the captured Torch anchor is eligible; never join arbitrary helper lines. + if (filename not in self.anchor_aliases and not (self.legacy_anchor and Path(filename.replace('\\', '/')).name == self.anchor.name)) or not (1 <= line <= self.anchor_lines): + return None + return {'file': 'mlir/ir_00_torch_input.mlir', 'line': line, 'column': column} + + def resolve_frame(self, frame: dict) -> list[dict]: + if not frame.get('line'): + return [] + file = self.file_for(frame.get('file', ''), frame.get('directory', '')) + if not file: + return [] + origins = [] + for location in self.files[file].get(frame['line'], []): + origin = self.canonical(location) + if origin and origin not in origins: + origins.append(origin) + return origins + + def resolve_frames(self, frames: list[dict]) -> tuple[list[dict], str]: + for index, frame in enumerate(frames): + origins = self.resolve_frame(frame) + if origins: + return origins, 'debug_location' if index == 0 else 'inline_callsite' + return [], 'unresolved' + + +def assembly_locations(text: str) -> dict[int, dict]: + """Location-state machine for LLVM's x86 GNU-style assembly output.""" + files: dict[int, tuple[str, str]] = {} + active = None + locations = {} + for number, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if re.match(r'\.(?:section|pushsection|popsection|previous|text|data|bss|cfi_endproc|size)\b', line): + active = None + if re.match(r'\.type\s+.+,\s*[@%]function', line): + active = None + if line.startswith('.file'): + try: + parts = shlex.split(line, comments=True) + except ValueError: + active = None + continue + if len(parts) >= 3 and parts[1].isdigit(): + # .file N "file" or .file N "directory" "file" [md5 ...] + directory, filename = ('', parts[2]) + if len(parts) >= 4 and parts[3] != 'md5': + directory, filename = parts[2:4] + files[int(parts[1])] = (directory, filename) + elif line.startswith('.loc'): + active = None + parts = line.split() + if len(parts) >= 4 and all(p.isdigit() for p in parts[1:4]): + file_id, source_line, column = map(int, parts[1:4]) + if source_line > 0 and file_id in files: + directory, filename = files[file_id] + active = {'file': filename, 'directory': directory, 'line': source_line, 'column': column} + if 'discriminator' in parts: + at = parts.index('discriminator') + 1 + if at < len(parts) and parts[at].isdigit(): active['discriminator'] = int(parts[at]) + elif active and raw[:1].isspace() and line and not line.startswith(('.', '#', '//')) and not line.endswith(':'): + locations[number] = dict(active) + return locations + + +def analyze_files(root: Path, mode: str = 'auto', progress=None) -> dict: + reports = {} + if mode == 'off': + return {'status': 'off', 'reports': reports, 'notes': ['Additional provenance was disabled.']} + executable = native_binary() + if executable is None: + if mode == 'required': + raise RuntimeError('Native analyzer unavailable. Install a native-enabled wheel or set COMPILERLENS_NATIVE.') + return {'status': 'fallback', 'reports': reports, 'notes': ['Native analyzer unavailable; metadata/assembly fallback used.']} + errors = [] + native_dir = root / 'native' + native_dir.mkdir(exist_ok=True) + for path in sorted((root / 'llvm').glob('*.ll')): + if progress: progress(f'Native provenance: {path.name}') + report_path = native_dir / (path.name + '.json') + view_path = native_dir / (path.name + '.annotated.ll') + command = [str(executable), '--input', str(path), '--output', str(report_path), '--annotated-ir', str(view_path)] + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=180) + except (OSError, subprocess.TimeoutExpired) as exc: + if mode == 'required': raise RuntimeError(f'Native analysis failed for {path.name}: {exc}') from exc + errors.append(f'{path.name}: {exc}') + continue + if result.returncode: + message = f'{path.name}: {result.stderr.strip()[:1500]}' + if mode == 'required': raise RuntimeError(message) + errors.append(message) + continue + report = json.loads(report_path.read_text()) + if report.get('schema_version') != PROTOCOL: + raise RuntimeError('Native analyzer protocol mismatch') + reports[path.name] = {'report': report, 'report_path': str(report_path.relative_to(root)), + 'view_path': str(view_path.relative_to(root)), 'input_sha256': digest(path)} + if not reports and mode == 'required': raise RuntimeError('No LLVM IR available for required native analysis.') + return {'status': 'native' if reports and not errors else 'partial' if reports else 'fallback', + 'reports': reports, 'notes': errors, 'binary': str(executable)} + + +def enrich_stages(root, stages, analysis): + """Return full precision sidecar and explicit origins for the legacy UI contract.""" + index = SourceIndex(root) + sidecar = {'schema_version': PROTOCOL, 'status': analysis['status'], 'notes': analysis['notes'], 'stages': {}} + source_sets = {} + for stage in stages: + records = [] + native = analysis['reports'].get(Path(stage.source_path).name) if stage.language == 'llvm' else None + if native: + stage.text = (root / native['view_path']).read_text() + lines = stage.text.splitlines() + markers = {} + pending = None + for number, line in enumerate(lines, 1): + if match := re.search(r'; compilerlens\.id=(\S+)', line): + pending = match[1] + elif pending and line.strip() and not line.lstrip().startswith(';'): + markers[pending] = number + pending = None + ops = [] + for record in native['report']['instructions']: + if record['id'] not in markers: raise RuntimeError('Native annotation/record mismatch') + number = markers[record['id']] + origins, relationship = index.resolve_frames(record['frames']) + op = Operation(id=f'{stage.id}:op{number}', stage_id=stage.id, line=number, + name=record['opcode'], dialect=_classify_ll(record['opcode']), + types=[record['type']], operands=record['operands'], text=lines[number-1].strip()[:400]) + ops.append(op) + records.append({**record, 'line': number, 'origins': origins, 'relationship': relationship}) + stage.ops = ops + stage.description += ' Native LLVM annotated view; original dump retained. IDs are snapshot-local.' + native_meta = {key: native[key] for key in ('report_path', 'view_path', 'input_sha256')} + native_meta['llvm_version'] = native['report']['llvm_version'] + else: + native_meta = None + asm = assembly_locations(stage.text) if stage.language == 'asm' and analysis['status'] != 'off' else {} + mlir = source_location_sets_by_line(stage.text) if stage.language == 'mlir' else {} + for op in stage.ops: + frames = [asm[op.line]] if op.line in asm else [] + if frames: + origins, relationship = index.resolve_frames(frames) + if origins: relationship = 'assembly_location' + else: + locations = mlir.get(op.line, [op.source_loc] if op.source_loc and analysis['status'] != 'off' else []) + origins = [o for loc in locations if (o := index.canonical(loc))] + relationship = 'compiler_location' if origins else 'unresolved' + if stage.name == 'torch-input': + text_line = stage.text.splitlines()[op.line - 1] + column = max(0, text_line.find(op.name)) + 1 + origins = [{'file': 'mlir/ir_00_torch_input.mlir', 'line': op.line, 'column': column}] + relationship = 'source_anchor' + records.append({'id': op.id, 'line': op.line, 'opcode': op.name, + 'origins': origins, 'frames': frames, 'relationship': relationship}) + by_line = {r['line']: r for r in records} + for op in stage.ops: + origins = by_line[op.line]['origins'] + source_sets[op.id] = [f"{o['file']}:{o['line']}:{o['column']}" for o in origins] + op.source_loc = source_sets[op.id][0] if len(origins) == 1 else None + stage.line_count = len(stage.text.splitlines()) + stage.byte_size = len(stage.text.encode()) + stage.op_count = len(stage.ops) + from .ingest.llvm_parser import histogram + stage.op_histogram = histogram([{'dialect': o.dialect} for o in stage.ops], 'dialect') + if records: + counts = {'instructions': len(records), + 'debug_locations': sum(any(f.get('line') for f in r.get('frames', [])) for r in records), + 'dispatch_resolved': sum(any(index.file_for(f.get('file', ''), f.get('directory', '')) and f.get('line') for f in r.get('frames', [])) for r in records), + 'source_associated': sum(bool(r['origins']) for r in records)} + reasons = {} + for r in records: + reason = ('resolved' if r['origins'] else 'no_location' if not r.get('frames') else + 'line_zero' if not any(f.get('line') for f in r['frames']) else + 'dispatch_without_anchor' if any(index.file_for(f.get('file', ''), f.get('directory', '')) for f in r['frames']) else + 'external_or_ambiguous_file') + r['resolution'] = reason + reasons[reason] = reasons.get(reason, 0) + 1 + entry = {'name': stage.name, 'language': stage.language, + 'source_path': stage.source_path, 'native': native_meta, 'records': records, + 'coverage': counts, 'gaps': reasons} + if native: entry['functions'] = native['report']['functions'] + sidecar['stages'][stage.id] = entry + return sidecar, source_sets + + +def object_lookup(root: Path, object_id: str, section: str, address: str): + binary = native_binary() + if not binary: raise RuntimeError('Object lookup requires the native analyzer.') + path = (root / object_id).resolve() + if not path.is_relative_to(root.resolve()) or not path.is_file(): + raise ValueError('Object must be a file within this run.') + result = subprocess.run([str(binary), '--object', str(path), '--section', section, '--address', address], + capture_output=True, text=True, timeout=60) + if result.returncode: raise RuntimeError(result.stderr.strip()) + report = json.loads(result.stdout) + index = SourceIndex(root) + report['origins'], report['relationship'] = index.resolve_frames(report['frames']) + return report + + +def object_sections(root: Path): + binary = native_binary() + if not binary: raise RuntimeError('Object section listing requires the native analyzer.') + reports = [] + for path in sorted((root / 'llvm').glob('*')): + if path.suffix not in ('.o', '.so'): continue + result = subprocess.run([str(binary), '--object', str(path), '--list-sections'], capture_output=True, text=True, timeout=60) + if result.returncode: raise RuntimeError(result.stderr.strip()) + report = json.loads(result.stdout) + reports.extend({'object': str(path.relative_to(root)), **s} for s in report['sections'] if s['executable'] and s['size']) + return reports diff --git a/compilerlens/queries.py b/compilerlens/queries.py new file mode 100644 index 0000000..f15db64 --- /dev/null +++ b/compilerlens/queries.py @@ -0,0 +1,129 @@ +"""Queries over saved runs. No compilation, network calls, or tensor imports.""" +from __future__ import annotations + +import difflib +from collections import Counter + + +def stage_for(artifact, selector): + matches = [s for s in artifact['stages'] if selector in (s['id'], s['name'])] + if len(matches) != 1: raise ValueError(f'Unknown or ambiguous stage {selector!r}; inspect --show stages lists valid IDs.') + return matches[0] + + +def module_for(artifact, path): + matches = [n for n in artifact.get('architecture', {}).get('nodes', []) if path in (n['id'], n.get('path'))] + if len(matches) != 1: raise ValueError(f'Unknown module {path!r}; inspect --show architecture lists valid paths.') + return matches[0] + + +def operations(stage, provenance): + saved = provenance.get('stages', {}).get(stage['id'], {}) + if saved.get('records'): + lines = stage['text'].splitlines() + return [{**r, 'id': r['id'] if r['id'].startswith(stage['id'] + ':') else f"{stage['id']}:{r['id']}", + 'native_id': r['id'] if saved.get('native') else None, + 'text': lines[r['line']-1] if r['line'] <= len(lines) else '', + 'name': r['opcode']} for r in saved['records']] + if stage['ops']: return stage['ops'] + from .ingest.llvm_parser import parse_llvm_ir, parse_asm + from .ingest.mlir_parser import parse_operations + parser = {'llvm': parse_llvm_ir, 'asm': parse_asm, 'mlir': parse_operations}.get(stage['language']) + if not parser: return [] + # Legacy artifacts may have trimmed indices; reconstruct display operations only. + parsed = parser(stage['text'], stage['id']) + return [{**op, 'id': f"{stage['id']}:op{op['line']}"} for op in parsed] + + +def inspect_report(artifact, provenance, *, show='summary', stage=None, module=None, lines=None): + selected = stage_for(artifact, stage) if stage else None + node = module_for(artifact, module) if module else None + source_lines = set(node.get('source_lines', [])) if node else None + if show == 'summary': + return {'compilation_id': artifact['compilation_id'], 'target': artifact['target'], + 'stage_count': len(artifact['stages']), 'evidence_count': len(artifact['evidence']), + 'architecture_mapping': artifact.get('architecture', {}).get('mapping_status'), + 'provenance': provenance.get('status', 'legacy'), 'notes': artifact['notes'], + 'coverage': {s['name']: s['coverage'] for s in provenance.get('stages', {}).values() if s['language'] in ('llvm', 'asm')}} + if show == 'architecture': + architecture = artifact.get('architecture', {}) + if node: return {**architecture, 'nodes': [n for n in architecture.get('nodes', []) if n['id'] == node['id'] or n.get('path', '').startswith(node.get('path', '') + '.')]} + return architecture + if show == 'stages': + return [{k: s[k] for k in ('id', 'name', 'language', 'track', 'line_count', 'op_count', 'source_path')} + for s in artifact['stages']] + if show == 'evidence': + return [e for e in artifact['evidence'] if selected is None or e['source_stage'] == selected['id']] + if not selected: raise ValueError(f'--show {show} requires --stage.') + if show == 'ir': + if node: raise ValueError('--module filters operations; use --show ops for module filtering.') + text = selected['text'].splitlines() + start, end = lines or (1, len(text)) + if start < 1 or end < start or end > len(text): raise ValueError(f'Line range must be within 1:{len(text)}.') + return {'stage': selected['name'], 'language': selected['language'], 'start_line': start, + 'end_line': end, 'text': '\n'.join(text[start-1:end])} + ops = operations(selected, provenance) + if source_lines is not None: + if selected['name'] == 'torch-input': ops = [op for op in ops if op['line'] in source_lines] + else: ops = [op for op in ops if any(o['line'] in source_lines for o in op.get('origins', []))] + if lines: ops = [op for op in ops if lines[0] <= op['line'] <= lines[1]] + return ops + + +def trace_report(artifact, provenance, *, op=None, module=None, source=None, from_stage=None, line=None, to='all'): + if sum(v is not None for v in (op, module, source, from_stage)) != 1: + raise ValueError('Choose one --op, --module, --source or --from-stage selector.') + if (from_stage is None) != (line is None): raise ValueError('--from-stage and --line must be supplied together.') + if not provenance: raise ValueError('This legacy artifact has no provenance sidecar; import its dump directory first.') + result = {'relationship': 'compiler-recorded source attribution; not persistent instruction identity', + 'provenance': provenance['status'], 'origins': [], 'matches': [], 'notes': list(provenance.get('notes', []))} + if from_stage: + stage = stage_for(artifact, from_stage) + if line < 1 or line > stage['line_count']: raise ValueError('Line is outside this stage.') + found = [r for r in operations(stage, provenance) if r['line'] == line] + result['matches'] = [{'stage': stage['name'], 'stage_id': stage['id'], **r} for r in found] + result['origins'] = list({(o['file'], o['line'], o['column']): o for r in found for o in r.get('origins', [])}.values()) + if not result['origins']: result['notes'].append('No resolved source attribution at this display line.') + return result + anchor = stage_for(artifact, 'torch-input') + columns = None + if source: + try: stage_id, line_str, col_str = source.rsplit(':', 2); source_line, column = int(line_str), int(col_str) + except ValueError: raise ValueError('Expected --source STAGE:LINE:COLUMN') from None + if stage_for(artifact, stage_id)['id'] != anchor['id']: raise ValueError('--source currently requires the torch-input anchor.') + if source_line < 1 or source_line > anchor['line_count'] or column < 1: raise ValueError('Source location is outside the anchor.') + selected_lines, columns = {source_line}, {column} + elif module: + node = module_for(artifact, module) + selected_lines = set(node.get('source_lines', [])) + result['module_mapping'] = node.get('mapping', 'unavailable') + else: + stage = stage_for(artifact, op.split(':', 1)[0]) + found = [r for r in operations(stage, provenance) if r['id'] == op] + if not found: raise ValueError(f'Unknown operation {op!r}; inspect --show ops lists IDs.') + if stage['id'] == anchor['id']: + selected_lines = {found[0]['line']} + else: + result['origins'] = found[0].get('origins', []) + selected_lines = {o['line'] for o in result['origins']} + # Preserve full origin tuples for a non-anchor operation. + exact = {(o['file'], o['line'], o['column']) for o in result['origins']} + for stage in artifact['stages']: + if stage['language'] not in (('llvm', 'asm') if to == 'all' else (to,)): continue + for r in operations(stage, provenance): + origins = [o for o in r.get('origins', []) if o['line'] in selected_lines and (columns is None or o['column'] in columns)] + if op and 'exact' in locals(): origins = [o for o in origins if (o['file'], o['line'], o['column']) in exact] + if origins: result['matches'].append({'stage': stage['name'], 'stage_id': stage['id'], **r}) + result['origins'] = list({(o['file'], o['line'], o['column']): o for r in result['matches'] for o in r.get('origins', []) if o['line'] in selected_lines and (columns is None or o['column'] in columns)}.values()) + if not result['matches']: result['notes'].append('No captured destination instructions resolve to this selector. Missing provenance is not inferred from operands.') + return result + + +def diff_report(artifact, provenance, before, after, mode='text'): + a, b = stage_for(artifact, before), stage_for(artifact, after) + if a['track'] != b['track']: raise ValueError(f"Incompatible comparison tracks: {a['track']} and {b['track']}.") + if mode == 'text': return ''.join(difflib.unified_diff(a['text'].splitlines(True), b['text'].splitlines(True), fromfile=a['name'], tofile=b['name'])) + ca, cb = (Counter(r.get('opcode', r.get('name')) for r in operations(s, provenance)) for s in (a, b)) + return {'from': a['name'], 'to': b['name'], 'track': a['track'], 'op_delta': b['op_count'] - a['op_count'], + 'operations': {k: cb[k]-ca[k] for k in sorted(ca.keys() | cb.keys()) if ca[k] != cb[k]}, + 'note': 'Operation-count comparison; not a proof of equivalence or a transformation attribution.'} diff --git a/compilerlens/server.py b/compilerlens/server.py new file mode 100644 index 0000000..9fa8609 --- /dev/null +++ b/compilerlens/server.py @@ -0,0 +1,58 @@ +"""Serve the existing browser application and saved artifacts from any install.""" +from pathlib import Path +import json +import threading +import webbrowser + +from .storage import load_run, workspace + + +def create_app(run, *, web_root=None): + from fastapi import FastAPI, HTTPException + from fastapi.responses import FileResponse + from fastapi.staticfiles import StaticFiles + from .backend.api.app import app as api + root, artifact, _ = load_run(run) + artifact_file = Path(run).expanduser().resolve() if Path(run).is_file() else root / 'artifact.json' + web = Path(web_root) if web_root else Path(__file__).parent / '_web' + if not (web / 'index.html').is_file(): + raise RuntimeError('Bundled webpage missing. Run python scripts/build_release.py or install a release wheel.') + app = FastAPI(title='CompilerLens') + # Share API behavior without adding static routes to its global app. + app.router.routes.extend(api.router.routes) + artifacts = workspace() / 'frontend/public/artifacts' + + @app.get('/artifacts/index.json') + def index(): + selected = json.loads((root / 'index.json').read_text()) if (root / 'index.json').exists() else { + 'workloads': [{'id': artifact['compilation_id'], 'title': artifact['compilation_id'], + 'description': '', 'source_entry': '', 'source_preview': '', + 'stage_count': len(artifact['stages']), 'op_count': 0, 'evidence_count': len(artifact['evidence'])}]} + if (artifacts / 'index.json').exists(): + others = json.loads((artifacts / 'index.json').read_text()).get('workloads', []) + ids = {w['id'] for w in selected['workloads']} + selected['workloads'].extend(w for w in others if w['id'] not in ids) + return selected + + @app.get('/artifacts/{name}.json') + def saved(name: str): + if name == artifact['compilation_id']: return FileResponse(artifact_file) + path = (artifacts / (name + '.json')).resolve() + if not path.is_relative_to(artifacts.resolve()) or not path.is_file(): raise HTTPException(404, 'Artifact not found') + return FileResponse(path) + + app.mount('/', StaticFiles(directory=web, html=True), name='web') + return app + + +def serve(run, *, port=8000, open_browser=False): + import uvicorn + if not 1 <= port <= 65535: raise ValueError('Port must be between 1 and 65535.') + app = create_app(run) + url = f'http://127.0.0.1:{port}' + print(f'CompilerLens: {url}', flush=True) + if open_browser: + timer = threading.Timer(1.0, webbrowser.open, args=(url,)) + timer.daemon = True + timer.start() + uvicorn.run(app, host='127.0.0.1', port=port) diff --git a/compilerlens/services.py b/compilerlens/services.py new file mode 100644 index 0000000..a2c3d29 --- /dev/null +++ b/compilerlens/services.py @@ -0,0 +1,185 @@ +"""Shared capture/import service used by the CLI, Python API and web backend.""" +from __future__ import annotations + +import dataclasses +import importlib +import importlib.util +import importlib.metadata +import json +import platform +import re +import shutil +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +from .storage import atomic_json, digest, slug + + +def save_inputs(inputs, root: Path) -> list[dict]: + import numpy as np + directory = root / 'inputs' + directory.mkdir(parents=True, exist_ok=True) + records = [] + for i, tensor in enumerate(inputs): + array = tensor.detach().cpu().contiguous().numpy() + path = directory / f'{i}.npy' + np.save(path, array, allow_pickle=False) + records.append({'path': str(path.relative_to(root)), 'shape': list(array.shape), + 'dtype': str(array.dtype), 'sha256': digest(path)}) + atomic_json(directory / 'index.json', records) + return records + + +def _fresh(out, name): + root = Path(out or Path.cwd() / 'compilerlens-runs' / f'{slug(name)}-{time.time_ns()}').expanduser().resolve() + root.mkdir(parents=True, exist_ok=False) + return root + + +def versions(): + result = {'python': platform.python_version(), 'platform': platform.platform()} + for package in ('compilerlens', 'torch', 'iree-base-compiler', 'iree-base-runtime', 'iree-turbine', 'transformers'): + try: result[package] = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: result[package] = 'not installed' + return result + + +def _finish(root, info, lineage, progress): + from .provenance import analyze_files + from .ingest.build import build_artifact, build_index + from .ingest.workloads.generated import spec_from_dump_dir + analysis = analyze_files(root, lineage, progress) + progress('Building artifact and source lineage') + spec = spec_from_dump_dir(root, info) + if not spec.stages: raise ValueError('No recognized compiler stages in the dump directory.') + artifact = build_artifact(spec, root=root, analysis=analysis) + atomic_json(root / 'artifact.json', dataclasses.asdict(artifact)) + atomic_json(root / 'index.json', build_index({spec.id: spec}, {spec.id: artifact})) + return analysis['status'] + + +def capture(model_id=None, *, example=None, python=None, out=None, seq_len=None, + revision=None, cpu='host', capture='standard', offline=False, seed=0, + lineage='auto', progress=None, max_parameters=None) -> Path: + """Compile one HF ID, built-in example, or local ``file.py:factory``. + + A factory returns ``(torch.nn.Module, tuple[Tensor, ...])``. Existing output + directories are refused. A failed run retains its status and diagnostic files. + """ + if sum(bool(v) for v in (model_id, example, python)) != 1: + raise ValueError('Choose exactly one MODEL_ID, --example, or --python.') + if not model_id and (seq_len is not None or revision is not None or offline): + raise ValueError('--seq-len, --revision and --offline apply only to Hugging Face models.') + if seq_len is not None and seq_len <= 0: raise ValueError('--seq-len must be positive.') + if capture not in ('standard', 'full') or lineage not in ('auto', 'required', 'off'): + raise ValueError('Invalid capture or lineage mode.') + if lineage == 'required': + from .provenance import native_binary + if not native_binary(): raise RuntimeError('Required native analyzer is unavailable; run compilerlens doctor.') + root = _fresh(out, model_id or example or Path(python.split(':')[0]).stem) + progress = progress or (lambda label: None) + started = time.monotonic() + state = {'schema_version': 1, 'status': 'running', 'created_at': datetime.now(timezone.utc).isoformat(), + 'parameters': dict(model_id=model_id, example=example, python=python, seq_len=(seq_len or 16) if model_id else None, + revision=revision, cpu=cpu, capture=capture, offline=offline, seed=seed, lineage=lineage), + 'versions': versions()} + def report(label): + state['phase'] = label + atomic_json(root / 'run.json', state) + progress(label) + try: + import torch + from .backend.compiler.runner import CompilerRunner, RunConfig, TRIM_ELIDE_ATTRS, TRIM_CODEGEN_PASSES, EXAMPLES + torch.manual_seed(seed) + report('Loading workload') + if model_id: + from .models.detect import detect + from .models.hf_wrapper import wrap, wrapper_source + detected = detect(model_id, revision=revision, seq_len=seq_len or 16, offline=offline) + module, inputs, info = wrap(detected) + source = wrapper_source(detected) + elif example: + if example not in EXAMPLES: raise ValueError(f'Unknown example: {example}. Choose {", ".join(EXAMPLES)}') + workload = importlib.import_module(EXAMPLES[example]) + module, inputs = workload.build_module(), workload.example_inputs() + info = {'model_id': example, 'kind': 'example'} + source = Path(workload.__file__).read_text() + else: + filename, separator, factory = python.rpartition(':') + if not separator or not factory.isidentifier(): raise ValueError('Expected --python file.py:factory') + filename = Path(filename).expanduser().resolve() + spec = importlib.util.spec_from_file_location('_compilerlens_workload_' + digest(filename)[:16], filename) + if not spec or not spec.loader: raise ValueError('Cannot load Python factory') + workload = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = workload + sys.path.insert(0, str(filename.parent)) + try: + spec.loader.exec_module(workload) + module, inputs = getattr(workload, factory)() + finally: + sys.path.remove(str(filename.parent)) + info = {'model_id': filename.stem, 'kind': 'python', 'factory': python, 'source_sha256': digest(filename)} + source = filename.read_text() + if not isinstance(module, torch.nn.Module) or not isinstance(inputs, (tuple, list)) or not inputs: + raise ValueError('Factory must return a torch module and nonempty tuple/list of example tensors.') + if any(not isinstance(t, torch.Tensor) for t in inputs): raise ValueError('Only tensor example inputs are supported.') + module.eval() + info['param_count'] = sum(p.numel() for p in module.parameters()) + if max_parameters is not None and info['param_count'] > max_parameters: + raise ValueError(f"{info['param_count']} parameters exceeds the {max_parameters} parameter interactive limit") + atomic_json(root / 'model_info.json', info) + (root / 'source.py').write_text(source) + state['model_info'] = info + state['inputs'] = save_inputs(inputs, root) + standard = capture == 'standard' + runner = CompilerRunner(RunConfig(layout='ingest', target_cpu=cpu, debug_symbols=True, + elide_attrs_larger_than=TRIM_ELIDE_ATTRS if standard else None, + pass_log_after=TRIM_CODEGEN_PASSES if standard else ())) + result = runner.run(module, tuple(inputs), name=root.name, output_dir=root, model_info=info, + on_progress=lambda label, _done, _total: report(label)) + manifest = json.loads(result.manifest_path.read_text()) + if manifest.get('errors'): + failure = manifest['errors'][0] + raise RuntimeError(f"Compiler failed at {failure['stage']}: {failure.get('stderr', '')[-2000:]}") + state['provenance'] = _finish(root, info, lineage, report) + state['status'] = 'complete' + # Retain _full: it is the authoritative export for recompile/reproducibility. + state['file_hashes'] = {str(p.relative_to(root)): digest(p) for folder in ('mlir', 'llvm', '_full') + for p in (root / folder).glob('*') if p.is_file()} + except BaseException as exc: + state.update(status='failed', error=f'{type(exc).__name__}: {exc}') + raise + finally: + state['elapsed_seconds'] = round(time.monotonic() - started, 3) + atomic_json(root / 'run.json', state) + return root + + +def import_dump(source, *, out=None, lineage='auto', progress=None) -> Path: + source = Path(source).expanduser().resolve() + if not source.is_dir() or not ((source / 'mlir').is_dir() or (source / 'llvm').is_dir()): + raise ValueError('Expected a dump directory containing mlir/ or llvm/.') + if out and Path(out).expanduser().resolve().is_relative_to(source): + raise ValueError('Import output must be outside the source directory.') + root = _fresh(out, source.name) + state = {'schema_version': 1, 'status': 'running', 'imported_from': str(source), 'versions': versions()} + atomic_json(root / 'run.json', state) + try: + # Copy only capture inputs, never arbitrary output or recursive run directories. + for name in ('mlir', 'llvm', 'passes', '_full', 'inputs', 'source.py', 'architecture.json', 'model_info.json', 'manifest.json'): + path = source / name + if path.is_dir(): shutil.copytree(path, root / name) + elif path.is_file(): shutil.copy2(path, root / name) + for path in source.glob('*.vmfb'): shutil.copy2(path, root / path.name) + info_path = root / 'model_info.json' + info = json.loads(info_path.read_text()) if info_path.exists() else {'model_id': source.name, 'kind': 'import'} + atomic_json(info_path, info) + state['provenance'] = _finish(root, info, lineage, progress or (lambda label: None)) + state['status'] = 'complete' + except BaseException as exc: + state.update(status='failed', error=f'{type(exc).__name__}: {exc}') + raise + finally: atomic_json(root / 'run.json', state) + return root diff --git a/compilerlens/storage.py b/compilerlens/storage.py new file mode 100644 index 0000000..dfcb443 --- /dev/null +++ b/compilerlens/storage.py @@ -0,0 +1,56 @@ +"""Durable run storage, independent of the installation directory.""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +from pathlib import Path + + +def atomic_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp = tempfile.mkstemp(prefix=f'.{path.name}-', dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, indent=2, allow_nan=False) + stream.write('\n') + os.replace(temp, path) + finally: + Path(temp).unlink(missing_ok=True) + + +def digest(path: Path) -> str: + h = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + h.update(chunk) + return h.hexdigest() + + +def slug(value: str) -> str: + return re.sub(r'[^a-zA-Z0-9_.-]', '_', value).strip('.') or 'workload' + + +def workspace() -> Path: + if os.environ.get('COMPILERLENS_WORKSPACE'): + return Path(os.environ['COMPILERLENS_WORKSPACE']).expanduser().resolve() + repo = Path(__file__).resolve().parent.parent + if (repo / 'frontend' / 'package.json').is_file(): + return repo + return Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'compilerlens' + + +def load_run(path: str | Path) -> tuple[Path, dict, dict]: + path = Path(path).expanduser().resolve() + artifact_path = path / 'artifact.json' if path.is_dir() else path + artifact = json.loads(artifact_path.read_text()) + if artifact.get('artifact_version') != '0.7' or not artifact.get('stages'): + raise ValueError('Expected a CompilerLens artifact version 0.7 with stages.') + root = artifact_path.parent + provenance_path = root / 'provenance.json' + provenance = json.loads(provenance_path.read_text()) if provenance_path.exists() else {} + if provenance and provenance.get('schema_version') != 1: + raise ValueError('Unsupported provenance schema; use a compatible CompilerLens version.') + return root, artifact, provenance diff --git a/compilerlens/toolchain.py b/compilerlens/toolchain.py new file mode 100644 index 0000000..a212c52 --- /dev/null +++ b/compilerlens/toolchain.py @@ -0,0 +1,9 @@ +"""Resolve installed console tools even when Python was invoked by absolute path.""" +from pathlib import Path +import shutil +import sys + + +def find_tool(name): + sibling = Path(sys.executable).parent / name + return str(sibling) if sibling.is_file() else shutil.which(name) diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..bf67890 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,187 @@ +# CompilerLens CLI and native provenance + +The `feature/experiments-cli` branch provides a complete Python package, the existing viewer, +a standalone LLVM analyzer and a loadable LLVM New Pass Manager plugin. The packaged analyzer +runs on actual IREE output. Source attribution and LLVM def-use edges are separate relationships. + +## Install the built wheel + +```bash +python -m pip install dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl \ + --extra-index-url https://download.pytorch.org/whl/cpu +compilerlens doctor +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens inspect runs/matmul +compilerlens view runs/matmul +``` + +The wheel is local; it has **not** been uploaded to PyPI. It bundles the existing React app, +Monaco workers, private native executable, LLVM license, zlib and zstd libraries/licenses. +End users do not need Node, CMake, `opt` or an LLVM SDK. Dependencies include the validated +PyTorch, Transformers, IREE compiler/runtime and Turbine versions in `pyproject.toml`. + +The first wheel targets Linux x86-64 and was built/tested on glibc 2.35 with Python 3.10. +It has a `linux_x86_64` tag, not an audited manylinux tag. macOS, Windows, ARM and older glibc +are not release targets. An LLVM 22 analyzer successfully parses the tested IREE 3.11.0 +LLVM 23 development snapshots; arbitrary future IR compatibility is not promised. + +## Capture parameters + +| Argument | Default | Meaning | +|---|---|---| +| `MODEL_ID` / `--example NAME` / `--python FILE:FACTORY` | Choose one | HF text model, packaged example, or local Python factory | +| `--seq-len N` | 16, HF only | Static export sequence length | +| `--revision REF` | Resolve main | Record a concrete HF commit; offline requires a cached ref or explicit cached SHA | +| `--offline` | false | Config and model loaders use cached resources only | +| `--cpu NAME` | host | IREE LLVM CPU target | +| `--out DIR` | Fresh timestamped directory | Refuse an existing directory | +| `--capture standard/full` | standard | Trim displayed constants and select pass snapshots, or capture full logs | +| `--seed N` | 0 | Seed generated weights/inputs; actual tensors are saved | +| `--lineage auto/required/off` | auto | Native analysis with explicit fallback, fail if unavailable/incompatible, or skip extra LLVM/assembly analysis | +| `--format text/json` | text | JSON stdout is reserved for the result; diagnostics use stderr | + +```bash +compilerlens compile sshleifer/tiny-gpt2 --seq-len 8 --out runs/gpt2 +compilerlens compile sshleifer/tiny-gpt2 --offline \ + --revision 5f91d94bd9cd7190a9f3216ff93cd1dd95f2c7be --out runs/gpt2-offline +compilerlens compile --example linear_relu --out runs/linear +compilerlens compile --python workload.py:build --out runs/local +compilerlens import examples/matmul --out runs/imported --lineage required +``` + +`build()` returns `(torch.nn.Module, tuple_of_example_tensors)`. The module is put in eval mode. +This explicitly executes local code. HF-only arguments are rejected for local/examples inputs. +The packaged examples are `matmul`, `linear_relu`, and `mini_transformer`. + +A failed capture keeps `run.json` with its error and phase. Successful runs include: + +```text +run.json resolved parameters, versions, tensor/file hashes, status +manifest.json compiler commands and diagnostics +inputs/*.npy actual tensor values (including decoder masks) +model_info.json +architecture.json torch.export module ownership when available +source.py +mlir/, llvm/, passes/ captured stages, every dispatch, LLVM checkpoints, assembly/objects +_full/ authoritative exports/resources retained for reproducibility +native/ per-snapshot JSON and annotated LLVM display views +artifact.json unchanged viewer schema 0.7 +provenance.json schema 1; origin sets, inline frames, def-use and coverage +index.json viewer workload entry +``` + +Standard capture still retains the authoritative export and can be large for large models. +Full capture can produce very large pass logs. Runs are self-contained and can be moved. +Legacy imports without manifests use unique captured-file aliases and report gaps where +metadata is absent; they cannot recover information the compiler never emitted. + +## Inspect, trace, compare and benchmark + +```bash +compilerlens inspect runs/gpt2 --show architecture +compilerlens inspect runs/gpt2 --show stages +compilerlens inspect runs/gpt2 --show evidence +compilerlens inspect runs/gpt2 --show ops --stage torch-input +compilerlens inspect runs/gpt2 --show ops --stage torch-input --module transformer.h.0.mlp.c_fc +compilerlens inspect runs/gpt2 --show ir --stage llvm-optimized --lines 100:140 +compilerlens trace runs/gpt2 --module transformer.h.0.mlp.c_fc --to asm +compilerlens trace runs/gpt2 --op s00:op3 --to llvm # use an ID actually listed by inspect +compilerlens trace runs/imported --source torch-input:3:10 --to asm +compilerlens trace runs/imported --from-stage target-asm --line 106 +compilerlens diff runs/gpt2 --from llvm-codegen --to llvm-optimized --mode semantic +compilerlens bench runs/gpt2 --workers 8 --repetitions 10 +``` + +Stage IDs, module paths and operation IDs are discovered from the saved run. Native IDs +are qualified by stage in CLI output. Their identity is the snapshot's SHA-256 plus the +function/block/instruction ordinal, **not** a persistent identity across optimization. +Use `--format json` for complete structured records. Text output uses Rich tables/trees and +IR highlighting; piped output works without terminal color or an interactive pager. + +Forward queries keep every explicit origin attached to matching records. Reverse queries +show unknown provenance explicitly. `.file`/`.loc` state resets on line zero, section changes +and function boundaries. Inline callsites are distinguished from primary debug locations. +A debug location is a source association; it does not prove one-to-one instruction ancestry. + +Coverage separately counts debug locations, dispatch-resolved locations, Torch anchors and +exact model-module associations. Native reports retain per-function counts so runtime helpers +can be distinguished from dispatches. Count changes across snapshots do not identify a +specific pass as causing lineage loss. Semantic diffs compare operation counts, not equivalence. +Diffs reject unrelated tracks/modules. + +Benchmarking passes actual saved NPY arrays to IREE and verifies their hashes. It reports +median, variance, worker count, host noise notes and reliability. Imported captures without +saved inputs cannot be benchmarked through this command. + +### Object addresses + +```bash +compilerlens inspect runs/matmul --show objects +compilerlens trace runs/matmul --object 'llvm/FILE.o' \ + --section '.text.FUNCTION' --address 0x100 --format json +``` + +Use the object path and nonempty executable section name from `--show objects`. IREE often +uses function sections and an empty `.text`. The address is a **section-relative byte offset**, +not an assembly display line, process address or file offset. Native LLVM DWARF lookup +returns recorded frames and joins them to the captured dispatch and Torch source where possible. + +## Build/develop the native pass + +```bash +cmake -S native -B build/native -G Ninja \ + -DLLVM_DIR=/path/to/llvm/lib/cmake/llvm -DCMAKE_BUILD_TYPE=Release +cmake --build build/native --parallel 2 +ctest --test-dir build/native --output-on-failure +/path/to/llvm/bin/opt -load-pass-plugin=build/native/CompilerLensPasses.so \ + -passes=compilerlens-provenance -disable-output run/llvm/FILE.optimized.ll +build/native/compilerlens-native --input run/llvm/FILE.optimized.ll \ + --output report.json --annotated-ir view.ll +``` + +The pass walks LLVM's real `Module`, `Instruction`, `DebugLoc` and `DILocation` APIs, recording +inline chains, scopes, discriminators, memory effects, vector types and operand instruction IDs. +It returns `PreservedAnalyses::all()`. CTest checks the standalone/plugin reports agree, +annotations parse, invalid IR fails, object lookups work and normalized IR is unchanged. + +Use an LLVM 22 SDK matching `opt` for the tested plugin. The CMake project permits LLVM 23 +for source-build development, which requires validation with that particular SDK. The plugin +ABI must match its host. `COMPILERLENS_NATIVE=/path/to/compilerlens-native` overrides the +private executable; `COMPILERLENS_WORKSPACE=/path` selects server storage. + +An optional [IREE source-build hook](../native/iree/README.md) brackets its actual optimization +pipeline, including O0. Its C++ hook is tested; the pinned source patch is supplied. A full +IREE source build has not been performed. The ordinary wheel uses emitted snapshots. +There is no assumed `iree-compile --load-pass-plugin` support. + +## Build the wheel and verify + +Install frontend dependencies with `npm ci` in `frontend`, and use Python with setuptools >=77. +After configuring the native build: + +```bash +python scripts/build_release.py +python -m unittest discover -s tests -v +python -m pip install dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl +python scripts/check_installed.py /path/to/installed/env/bin/python /path/to/run --browser +``` + +`build_release.py` builds/tests C++, installs private assets, bundles the unchanged application, +runs TypeScript checking and builds the wheel. It accepts `--cmake /path/to/cmake` and +`--llvm-dir /path/to/llvm/lib/cmake/llvm`. Source wheel builds require prepared assets; +`--assets-only` prepares them without building the wheel. Browser verification requires a +Playwright Chromium installation. Browser components and styling are unchanged. + +On this workspace's LLVM SDK, configure using: + +```bash +/pkg/qct/software/cmake/3.31.5/bin/cmake -S native -B build/native -G Ninja \ + -DLLVM_DIR=/pkg/qct/software/llvm/22.1.8/lib/cmake/llvm \ + -DCMAKE_C_COMPILER=/pkg/qct/software/llvm/22.1.8/bin/clang \ + -DCMAKE_CXX_COMPILER=/pkg/qct/software/llvm/22.1.8/bin/clang++ \ + -DCMAKE_CXX_FLAGS=--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/11 \ + -DCMAKE_MAKE_PROGRAM=/pkg/qct/software/ninja/1.12.1/ninja \ + -DCOMPILERLENS_ZSTD_LIBRARY=/usr/lib/x86_64-linux-gnu/libzstd.so.1 \ + -DCMAKE_BUILD_TYPE=Release +python scripts/build_release.py --cmake /pkg/qct/software/cmake/3.31.5/bin/cmake +``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..13a33ba --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,368 @@ +# CompilerLens: TestPyPI to GitHub and PyPI + +Run these steps in order. They describe actions for you to perform; creating this checklist +has not committed, pushed, merged, tagged or published anything. + +Verified on 25 September 2026: + +- TestPyPI has `compilerlens` version `0.1.0` at https://test.pypi.org/project/compilerlens/0.1.0/. +- Its uploaded wheel's SHA-256 matches the local repaired wheel in `wheelhouse/`. +- The wheel is `compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`. +- GitHub remote: `git@github.com:Soumyaworks/CompilerLens.git`; default branch: `main`. +- Current branch: `feature/experiments-cli`; implementation changes are not yet committed. +- No production PyPI project is publicly visible under `compilerlens` at the time of checking. + TestPyPI does not reserve that name on production PyPI. + +Local prepublication checks for this uploaded wheel are now complete: fresh TestPyPI download, +fresh dependency installation from the normal indexes, `pip check`, required native matmul +capture (33 stages), assembly tracing (208 matches), benchmarking, and the installed browser +flow all ran successfully. All 21 Python tests, native CTest and wheel metadata checks passed. +The benchmark flagged its timings as noisy; it is not a performance result. The release source +files are staged, with generated artifacts and unrelated experiments excluded. No commit, push +or production publication was performed during these checks. + +Keep the uploaded `0.1.0` wheel unchanged while following these steps. If code, dependencies or +packaged metadata need fixing, use a new version and repeat the TestPyPI checks. Git-only +publication notes and ignore rules can be committed alongside the existing release. + +## 1. Start in the repository + +Use one terminal for the commands so the temporary-directory variables remain available. + +```bash +cd /local/mnt/workspace/compilers/CompilerLens +CLENS_REPO="$PWD" +git branch --show-current +git status --short +``` + +The branch should be `feature/experiments-cli`. Do not use `git add .`: the working directory +also contains unrelated, untracked experimental models and presentation files. + +## 2. Install the actual TestPyPI release in a fresh environment + +This verifies what another user downloads, including resolving real dependencies. Earlier +isolated wheel checks reused local dependency files, so this step still matters. + +Use a Linux x86-64 machine with glibc 2.35 or newer. Python 3.10 is the tested interpreter. +The current release does not provide macOS, Windows or ARM wheels. + +```bash +CLENS_CHECK_DIR="$(mktemp -d /tmp/compilerlens-release.XXXXXX)" +python3 -m venv --without-pip "$CLENS_CHECK_DIR/venv" + +# Bootstrap pip using the working repository environment. This also works on this +# workstation, where ordinary `python3 -m venv` previously lacked ensurepip. +"$CLENS_REPO/.venv/bin/python" -m pip \ + --python "$CLENS_CHECK_DIR/venv/bin/python" install --upgrade pip + +# Download only CompilerLens from TestPyPI. +"$CLENS_CHECK_DIR/venv/bin/python" -m pip download \ + --index-url https://test.pypi.org/simple/ \ + --no-deps --only-binary=:all: \ + --dest "$CLENS_CHECK_DIR/downloads" \ + compilerlens==0.1.0 + +# Install that downloaded wheel; resolve dependencies from their normal indexes. +"$CLENS_CHECK_DIR/venv/bin/python" -m pip install \ + --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + "$CLENS_CHECK_DIR/downloads/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl" + +"$CLENS_CHECK_DIR/venv/bin/python" -m pip check +``` + +Downloading the package separately keeps TestPyPI out of dependency resolution. Do not treat +an installation with `--no-deps` as a successful test of the complete user installation. + +## 3. Exercise the installed CLI and viewer + +Run outside the checkout, so Python cannot accidentally import the source directory. + +```bash +cd "$CLENS_CHECK_DIR" +"$CLENS_CHECK_DIR/venv/bin/compilerlens" --version +"$CLENS_CHECK_DIR/venv/bin/compilerlens" doctor + +"$CLENS_CHECK_DIR/venv/bin/compilerlens" compile \ + --example matmul --out "$CLENS_CHECK_DIR/matmul" --lineage required + +"$CLENS_CHECK_DIR/venv/bin/compilerlens" inspect "$CLENS_CHECK_DIR/matmul" +"$CLENS_CHECK_DIR/venv/bin/compilerlens" inspect "$CLENS_CHECK_DIR/matmul" --show stages +"$CLENS_CHECK_DIR/venv/bin/compilerlens" trace \ + "$CLENS_CHECK_DIR/matmul" --module model --to asm + +"$CLENS_CHECK_DIR/venv/bin/compilerlens" bench \ + "$CLENS_CHECK_DIR/matmul" --workers 2 --repetitions 5 + +"$CLENS_CHECK_DIR/venv/bin/compilerlens" view \ + "$CLENS_CHECK_DIR/matmul" --port 8001 +``` + +Open `http://127.0.0.1:8001`. Select the workload, open its architecture, then open the compiler +pipeline and inspect IR/assembly. On a remote machine, forward port 8001 through SSH to your +browser machine. Press Ctrl+C in the server terminal when finished. + +Expected: `doctor` reports ready, native analysis succeeds, the trace has matching instructions, +and the existing architecture/workspace/editor load. A benchmark can legitimately be marked +unreliable on a busy machine; that is not an installation failure. + +Optional automated browser check, using the existing local Playwright installation: + +```bash +cd "$CLENS_REPO" +.venv/bin/python scripts/check_installed.py \ + "$CLENS_CHECK_DIR/venv/bin/python" "$CLENS_CHECK_DIR/matmul" \ + --browser --browsers-path /tmp/compilerlens-browsers +``` + +That browser directory was downloaded during implementation. If it has been removed, install +Playwright's Chromium again as described in `docs/CLI.md`. + +## 4. Run the source checks and validate the release file + +```bash +cd "$CLENS_REPO" +.venv/bin/python -m unittest discover -s tests -v +/pkg/qct/software/cmake/3.31.5/bin/ctest --test-dir build/native --output-on-failure +.venv/bin/python -m twine check \ + wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl +``` + +The current suite has 21 Python tests and the native CTest integration check. The CTest command +assumes the existing configured native build on this workstation; use `docs/CLI.md` to build +it on another machine. The release script already runs TypeScript checking when building assets. + +Keep the repaired wheel in `wheelhouse/`. That is the file to publish, not the original generic +`linux_x86_64` wheel in `dist/`. + +## 5. Stage exactly the release's source files + +The following files/directories belong in this feature commit: + +| Path | Purpose | +|---|---| +| `compilerlens/` | Canonical Python package, CLI, capture/query services, examples and migrated implementation | +| `backend/`, `ingest/`, `models/` | Compatibility imports and removal of the old implementation paths | +| `native/` | C++ pass/analyzer, CMake, LLVM license, fixtures and optional IREE hook | +| `pyproject.toml`, `setup.py`, `MANIFEST.in` | Package metadata and wheel/source packaging | +| `frontend/vite.release.config.ts` | Bundle the existing frontend for installation | +| `scripts/build_release.py`, `scripts/check_installed.py`, `scripts/check_viewer.mjs` | Release build and installation/browser validation | +| `scripts/compile_hf_model.py` | Updated imports for the package move | +| `tests/test_architecture.py`, `tests/test_llvm_lineage.py`, `tests/test_cli_provenance.py` | Existing adjusted tests and new regression tests | +| `.gitignore`, `README.md`, `FINALS_5_DAY_PLAN.md`, `docs/CLI.md`, `docs/RELEASING.md` | Ignore rules and documentation | + +Run these scoped staging commands: + +```bash +cd "$CLENS_REPO" + +git add -A -- compilerlens/ backend/ ingest/ models/ native/ \ + ':(exclude)**/__pycache__/**' + +git add -- \ + .gitignore README.md FINALS_5_DAY_PLAN.md \ + pyproject.toml setup.py MANIFEST.in \ + docs/CLI.md docs/RELEASING.md \ + frontend/vite.release.config.ts \ + scripts/build_release.py scripts/check_installed.py scripts/check_viewer.mjs \ + scripts/compile_hf_model.py \ + tests/test_architecture.py tests/test_llvm_lineage.py tests/test_cli_provenance.py + +git diff --cached --check +git diff --cached --stat +git diff --cached --name-status +git status --short +``` + +`-A` is scoped to the listed source directories so the package moves include both additions +and removals. The current `.gitignore` excludes build products, including `wheelhouse/`. +The explicit exclusion avoids staging the old tracked bytecode file as part of this release. + +Leave these local/generated paths out of the feature commit: + +- `.venv/`, `build/`, `dist/`, `wheelhouse/`, `*.egg-info/`, `node_modules/`, `__pycache__/`. +- `compilerlens/_bin/`, `compilerlens/_web/`, `frontend/public/artifacts/`, `lib/*.so`. +- `runs/`, `jobs/`, `compilerlens-runs/` and generated `.vmfb`, `.bc`, `.o`, `.so` files. +- Currently untracked `archived_models/`, `experiments/`, `presentation/` and the generated + model directories under `examples/`. They are separate work, not required by this package. +- API tokens, `.pypirc` credentials and any environment file containing secrets. + +This does not remove existing tracked example fixtures. Generated native/web assets belong in +the wheel and GitHub Release attachments; their sources and build instructions belong in Git. +Remaining `??` entries for unrelated experiments are expected after staging. + +## 6. Commit and push the feature branch + +After reviewing the staged diff: + +```bash +git commit -m "Add installable CLI and native LLVM provenance" +git push -u origin feature/experiments-cli +``` + +The code will then be visible on the feature branch at: + +https://github.com/Soumyaworks/CompilerLens/tree/feature/experiments-cli + +Open a pull request into `main`: + +https://github.com/Soumyaworks/CompilerLens/compare/main...feature/experiments-cli + +Suggested title: `Add installable CLI and native LLVM provenance`. + +Describe the packaged CLI, native reporting pass, source/assembly/DWARF tracing, unchanged +viewer and validation results. State that the release wheel targets Linux x86-64/glibc 2.35+, +and that the optional full IREE source build has not been validated. + +Review and merge the PR. If review changes runtime code or package metadata, build and test a +new version before continuing; do not publish the old wheel as if it contained those changes. + +## 7. Publish the tested wheel to production PyPI + +Create/log in to your account at https://pypi.org, verify your email, complete account security +setup and create a **production PyPI** API token at https://pypi.org/manage/account/token/. +For the first upload use an account-wide token; after the project exists, a project-scoped token +can be used. Your TestPyPI token will not work on production PyPI. + +You may publish the same `0.1.0` artifact to production: the two indexes are independent. + +```bash +cd "$CLENS_REPO" +.venv/bin/python -m twine upload \ + --repository-url https://upload.pypi.org/legacy/ \ + wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl +``` + +Enter the production API token when prompted. If Twine asks for a username, use `__token__`. +Keep tokens out of shell commands, Git and screenshots. Explicitly naming the endpoint avoids +accidentally uploading to TestPyPI through a local repository configuration. + +Confirm the new release at https://pypi.org/project/compilerlens/0.1.0/. +If the name cannot be registered, TestPyPI ownership does not grant production ownership; resolve +that before announcing an installation command under this name. + +## 8. Verify installation from production PyPI + +Use another fresh environment, so an already installed TestPyPI package cannot satisfy the request. + +```bash +CLENS_PROD_CHECK="$(mktemp -d /tmp/compilerlens-pypi.XXXXXX)" +python3 -m venv --without-pip "$CLENS_PROD_CHECK/venv" +"$CLENS_REPO/.venv/bin/python" -m pip \ + --python "$CLENS_PROD_CHECK/venv/bin/python" install --upgrade pip + +"$CLENS_PROD_CHECK/venv/bin/python" -m pip install \ + --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + compilerlens==0.1.0 + +cd "$CLENS_PROD_CHECK" +"$CLENS_PROD_CHECK/venv/bin/python" -m pip check +"$CLENS_PROD_CHECK/venv/bin/compilerlens" doctor +"$CLENS_PROD_CHECK/venv/bin/compilerlens" compile \ + --example matmul --out "$CLENS_PROD_CHECK/matmul" --lineage required +``` + +Once verified, ordinary users can use `pip install compilerlens` on supported systems. The +PyTorch CPU extra index is recommended when explicitly choosing CPU-only PyTorch dependencies. + +## 9. Tag the merged source and create a GitHub Release + +After the PR is merged, update local `main` and inspect the commit being tagged: + +```bash +cd "$CLENS_REPO" +git switch main +git pull --ff-only origin main +git log -1 --oneline +git tag --list v0.1.0 +``` + +If the tag does not already exist, and this commit contains the released implementation: + +```bash +git tag -a v0.1.0 -m "CompilerLens 0.1.0" +git push origin v0.1.0 +``` + +If `main` has acquired other runtime changes since your release commit, tag the actual reviewed +release commit instead. Never move an existing published tag to different code. + +On https://github.com/Soumyaworks/CompilerLens/releases/new: + +1. Select tag `v0.1.0` and title `CompilerLens 0.1.0`. +2. Describe the CLI/native pass, supported platform and install command. +3. Link to https://pypi.org/project/compilerlens/0.1.0/ and `docs/CLI.md`. +4. Attach `wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`. +5. Publish the release. + +Use the exact tested wheel as the release attachment. GitHub's automatic source archive is +useful for development, but building a wheel from source also requires the native/web build +steps. The binary wheel is the easy installation path. + +Your source appears in the repository and its release appears under **Releases**. Publishing +to PyPI does not automatically create a GitHub Release or a GitHub Packages entry. + +## 10. Make subsequent changes without renaming the project + +Keep `name = "compilerlens"`, the `compilerlens` import and console command, and the GitHub +repository name. Release new versions instead of overwriting existing files. + +For a `0.1.1` release, update the version strings in these six places: + +```text +pyproject.toml project.version +compilerlens/__init__.py __version__ +native/CMakeLists.txt project version +native/tools/main.cpp --version output +native/lib/Plugin.cpp plugin version +native/lib/Provenance.cpp pass_version +``` + +Do not bump artifact/provenance schema versions merely because the package version changed. +Update documentation examples as needed; historical release notes should retain their versions. + +The existing uploaded wheel has no project URL metadata. Add this for the next version in +`pyproject.toml` so the PyPI page links back to GitHub: + +```toml +[project.urls] +Repository = "https://github.com/Soumyaworks/CompilerLens" +Issues = "https://github.com/Soumyaworks/CompilerLens/issues" +Documentation = "https://github.com/Soumyaworks/CompilerLens/blob/main/docs/CLI.md" +``` + +Then build and test that new version on this workstation: + +```bash +cd "$CLENS_REPO" +.venv/bin/python scripts/build_release.py \ + --cmake /pkg/qct/software/cmake/3.31.5/bin/cmake + +.venv/bin/auditwheel repair \ + dist/compilerlens-0.1.1-py3-none-linux_x86_64.whl --wheel-dir wheelhouse + +.venv/bin/python -m twine check \ + wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl + +.venv/bin/python -m twine upload --repository testpypi \ + wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl +``` + +Repeat the clean-install checks, commit/review the source, upload that exact new wheel to +production, and create tag/release `v0.1.1`. Use the actual tag emitted by auditwheel if the +build environment changes. Avoid `wheelhouse/*.whl` once it contains multiple releases. + +Users choose when to upgrade: + +```bash +pip install --upgrade compilerlens +# Or keep a specific release: +pip install compilerlens==0.1.0 +``` + +Older installations are not automatically changed by a new PyPI upload. Keep CLI/Python +interfaces compatible where possible and document breaking changes. Once manual releases +are working, GitHub Actions plus PyPI Trusted Publishing can automate this same tested +sequence without storing a long-lived upload token; that automation is not configured here yet. 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/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/native/CMakeLists.txt b/native/CMakeLists.txt new file mode 100644 index 0000000..f9f17ce --- /dev/null +++ b/native/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.20) +project(CompilerLensNative VERSION 0.1.0 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(CompilerLensProvenance STATIC lib/Provenance.cpp) +llvm_update_compile_flags(CompilerLensProvenance) +add_llvm_pass_plugin(CompilerLensPasses lib/Plugin.cpp) +target_link_libraries(CompilerLensPasses PRIVATE CompilerLensProvenance) +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 CompilerLensProvenance ${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 CompilerLensProvenance ${CLENS_LIBS}) + add_test(NAME provenance 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/native/LLVM-LICENSE.txt b/native/LLVM-LICENSE.txt new file mode 100644 index 0000000..5715176 --- /dev/null +++ b/native/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/native/include/compilerlens/Checkpoint.h b/native/include/compilerlens/Checkpoint.h new file mode 100644 index 0000000..ac529f2 --- /dev/null +++ b/native/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/Provenance.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(ProvenancePass(&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/native/include/compilerlens/Provenance.h b/native/include/compilerlens/Provenance.h new file mode 100644 index 0000000..2f7aca3 --- /dev/null +++ b/native/include/compilerlens/Provenance.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 ProvenancePass : public llvm::PassInfoMixin { + llvm::json::Object *Result; +public: + explicit ProvenancePass(llvm::json::Object *Result = nullptr) : Result(Result) {} + llvm::PreservedAnalyses run(llvm::Module &M, llvm::ModuleAnalysisManager &); + static bool isRequired() { return true; } +}; +} diff --git a/native/iree/.gitattributes b/native/iree/.gitattributes new file mode 100644 index 0000000..e3aa58b --- /dev/null +++ b/native/iree/.gitattributes @@ -0,0 +1,2 @@ +# Unified patches require a space prefix on empty context lines. +*.patch whitespace=-blank-at-eol diff --git a/native/iree/CompilerLens.cmake b/native/iree/CompilerLens.cmake new file mode 100644 index 0000000..9eef99a --- /dev/null +++ b/native/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_native "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) +target_sources(iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses PRIVATE + "${_compilerlens_native}/lib/Provenance.cpp") +target_include_directories(iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses PRIVATE + "${_compilerlens_native}/include") diff --git a/native/iree/LLVMIRPasses.patch b/native/iree/LLVMIRPasses.patch new file mode 100644 index 0000000..0d41169 --- /dev/null +++ b/native/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/native/iree/README.md b/native/iree/README.md new file mode 100644 index 0000000..f1435cb --- /dev/null +++ b/native/iree/README.md @@ -0,0 +1,27 @@ +# Optional IREE source-build hook + +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/native/lib/Plugin.cpp b/native/lib/Plugin.cpp new file mode 100644 index 0000000..86de7d1 --- /dev/null +++ b/native/lib/Plugin.cpp @@ -0,0 +1,13 @@ +#include "compilerlens/Provenance.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.1.0", [](llvm::PassBuilder &PB) { + PB.registerPipelineParsingCallback([](llvm::StringRef Name, llvm::ModulePassManager &MPM, + llvm::ArrayRef) { + if (Name != "compilerlens-provenance") return false; + MPM.addPass(compilerlens::ProvenancePass()); + return true; + }); + }}; +} diff --git a/native/lib/Provenance.cpp b/native/lib/Provenance.cpp new file mode 100644 index 0000000..fc396d6 --- /dev/null +++ b/native/lib/Provenance.cpp @@ -0,0 +1,107 @@ +#include "compilerlens/Provenance.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.1.0"}, {"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 ProvenancePass::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/native/tests/check_reports.py b/native/tests/check_reports.py new file mode 100644 index 0000000..8a523f9 --- /dev/null +++ b/native/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-provenance', '-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/native/tests/checkpoint.cpp b/native/tests/checkpoint.cpp new file mode 100644 index 0000000..6768b59 --- /dev/null +++ b/native/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/native/tests/fixture.ll b/native/tests/fixture.ll new file mode 100644 index 0000000..b5250c5 --- /dev/null +++ b/native/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/native/tools/main.cpp b/native/tools/main.cpp new file mode 100644 index 0000000..778fcce --- /dev/null +++ b/native/tools/main.cpp @@ -0,0 +1,112 @@ +#include "compilerlens/Provenance.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.1.0 LLVM " << LLVM_VERSION_STRING << " protocol 1\n"; + }); + cl::ParseCommandLineOptions(argc, argv, "CompilerLens native provenance 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::ProvenancePass(&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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..31ec4ca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "compilerlens" +version = "0.1.0" +description = "Inspect model compilation and trace compiler-recorded provenance 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.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..732d48d --- /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/native 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', 'native', '-B', 'build/native', '-DCMAKE_BUILD_TYPE=Release', f'-DLLVM_DIR={args.llvm_dir}') + if not (ROOT / 'build/native/CMakeCache.txt').exists(): parser.error('Configure build/native first or supply --llvm-dir.') + run(args.cmake, '--build', 'build/native', '--parallel', '2') + run(str(Path(args.cmake).with_name("ctest")) if "/" in args.cmake else "ctest", "--test-dir", "build/native", "--output-on-failure") + run(args.cmake, '--install', 'build/native', '--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 / 'native/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_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..83a1238 100644 --- a/scripts/compile_hf_model.py +++ b/scripts/compile_hf_model.py @@ -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: 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_provenance.py b/tests/test_cli_provenance.py new file mode 100644 index 0000000..8e43f8f --- /dev/null +++ b/tests/test_cli_provenance.py @@ -0,0 +1,142 @@ +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.provenance 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 / 'provenance.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) + + +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_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): From 3d22611bd2e007b3448b1aa28d66cfa5b7fc9584 Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Mon, 28 Sep 2026 01:35:34 +0530 Subject: [PATCH 02/14] Adding LLVM analysis pass layer --- FINALS_5_DAY_PLAN.md | 713 ------------------ MANIFEST.in | 2 +- README.md | 344 +++++---- compilerlens/__init__.py | 2 +- compilerlens/backend/api/app.py | 2 +- compilerlens/cli.py | 16 +- compilerlens/ingest/build.py | 8 +- compilerlens/ingest/schema.py | 2 +- compilerlens/{provenance.py => lineage.py} | 6 +- compilerlens/models/architecture.py | 2 +- compilerlens/queries.py | 30 +- compilerlens/services.py | 8 +- compilerlens/storage.py | 13 +- docs/CLI.md | 47 +- docs/RELEASING.md | 63 +- frontend/src/ArchitectureExplorerPage.tsx | 2 +- frontend/src/styles/architecture.css | 6 +- {native => llvm}/CMakeLists.txt | 14 +- {native => llvm}/LLVM-LICENSE.txt | 0 llvm/README.md | 53 ++ .../include/compilerlens/Checkpoint.h | 4 +- .../include/compilerlens/Lineage.h | 4 +- {native => llvm}/iree/.gitattributes | 0 {native => llvm}/iree/CompilerLens.cmake | 6 +- {native => llvm}/iree/LLVMIRPasses.patch | 0 {native => llvm}/iree/README.md | 3 + .../Provenance.cpp => llvm/lib/Lineage.cpp | 4 +- {native => llvm}/lib/Plugin.cpp | 6 +- {native => llvm}/tests/check_reports.py | 2 +- {native => llvm}/tests/checkpoint.cpp | 0 {native => llvm}/tests/fixture.ll | 0 {native => llvm}/tools/main.cpp | 6 +- pyproject.toml | 2 +- scripts/build_release.py | 14 +- ..._cli_provenance.py => test_cli_lineage.py} | 18 +- 35 files changed, 431 insertions(+), 971 deletions(-) delete mode 100644 FINALS_5_DAY_PLAN.md rename compilerlens/{provenance.py => lineage.py} (99%) rename {native => llvm}/CMakeLists.txt (79%) rename {native => llvm}/LLVM-LICENSE.txt (100%) create mode 100644 llvm/README.md rename {native => llvm}/include/compilerlens/Checkpoint.h (93%) rename native/include/compilerlens/Provenance.h => llvm/include/compilerlens/Lineage.h (72%) rename {native => llvm}/iree/.gitattributes (100%) rename {native => llvm}/iree/CompilerLens.cmake (74%) rename {native => llvm}/iree/LLVMIRPasses.patch (100%) rename {native => llvm}/iree/README.md (90%) rename native/lib/Provenance.cpp => llvm/lib/Lineage.cpp (97%) rename {native => llvm}/lib/Plugin.cpp (76%) rename {native => llvm}/tests/check_reports.py (97%) rename {native => llvm}/tests/checkpoint.cpp (100%) rename {native => llvm}/tests/fixture.ll (100%) rename {native => llvm}/tools/main.cpp (96%) rename tests/{test_cli_provenance.py => test_cli_lineage.py} (90%) diff --git a/FINALS_5_DAY_PLAN.md b/FINALS_5_DAY_PLAN.md deleted file mode 100644 index b5a1f56..0000000 --- a/FINALS_5_DAY_PLAN.md +++ /dev/null @@ -1,713 +0,0 @@ -# CompilerLens finals: a complete CLI and native LLVM lineage - -**Updated:** 25 September 2026 - -**Status:** core implementation delivered on `feature/experiments-cli`; see the implementation review below. The original design follows for context. - -**Scope:** keep the existing webpage; expose its capabilities through a clear CLI; add a C++ LLVM pass and extend provenance into assembly. - -**Planning assumption:** two contributors, following the existing compiler/backend and product/frontend ownership split, over five days. - -## Implementation review — 25 September 2026 - -The CLI, native LLVM integration and existing-viewer packaging are implemented. The current -command reference is [docs/CLI.md](docs/CLI.md). The Python implementation now lives under -`compilerlens/`, with checkout compatibility imports for the older commands. - -| Deliverable | Implementation | -|---|---| -| Pip package and console entrypoint | `pyproject.toml`, `setup.py`, `compilerlens/cli.py` | -| Shared capture/import service | `compilerlens/services.py`; used by web Explore and CLI | -| Persistent capture, actual inputs, resolved revisions | `run.json`, `manifest.json`, `inputs/*.npy`, content hashes; existing output directories refused | -| Inspect architecture/stages/evidence/operations/IR | Rich terminal output and JSON; uses the existing artifact schema | -| C++ New Pass Manager pass | `native/lib/Provenance.cpp`, `Plugin.cpp`, CMake; read-only LLVM traversal | -| Native integration | Private executable analyzes every captured LLVM checkpoint; explicit fallback/required/off modes | -| Complete kernel capture | All emitted dispatch kernels and LLVM modules represented, with separate comparison tracks | -| Forward/reverse tracing | `compilerlens/queries.py`, `provenance.py`; explicit origin sets, inline frames and unknown locations | -| Provenance audit | Debug, dispatch, source and exact-module counts; per-function statistics and gap reasons | -| Assembly and object addresses | `.file`/`.loc` state machine plus native LLVM object/DWARF lookup; `inspect --show objects` lists usable sections | -| Existing viewer | Bundled unchanged React app/Monaco workers; loopback server and shared API routes | -| Exact-input benchmark | NPY inputs preserve decoder causal masks; hash checks and timing reliability metadata | -| Release build | `scripts/build_release.py`; native CTest, TypeScript checking, web bundle and platform wheel | -| Optional in-IREE checkpoints | C++ hook compiled/tested and patch checked against pinned IREE source; full IREE source rebuild remains optional/unperformed | - -The local release artifact is `dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl`. -It has not been published to PyPI. The wheel bundles the native executable and its nonbaseline -compression libraries; users need no LLVM SDK, `opt`, Node or CMake. This first wheel is -validated on Linux x86-64/glibc 2.35/Python 3.10 and is not an audited manylinux release. - -Validation completed during implementation: - -- 21 Python tests covering existing architecture/LLVM behavior, fused/ambiguous source - locations, assembly location resets, selectors, JSON/error contracts, input preservation, - output protection and offline revision resolution. -- Native CTest: standalone/plugin agreement, unchanged normalized LLVM IR, inline frames, - def-use/memory records, annotated IR validation, malformed input rejection, object/DWARF - bounds and optional before/after checkpoint hook. -- Fresh matmul compilation with required native analysis and exact-input benchmarking. -- A separate environment installed the wheel and compiled matmul from outside the checkout - with no SDK on PATH. Its executable matched NumPy matrix multiplication on the saved - tensors (maximum absolute error 0 in that run). Dependencies were copied locally for this - offline install check; fresh internet dependency resolution was not exercised. -- Offline tiny-GPT2 capture at commit `5f91d94bd9cd7190a9f3216ff93cd1dd95f2c7be`, sequence - length 8: all 17 dispatch kernels, 58 stages, required native analysis succeeded. - Optimized LLVM: 3,253 instructions / 892 source-associated; assembly: 2,462 instructions / - 1,086 source-associated. These are distinct measures, not an optimization score. -- Native object lookup on a fresh matmul object resolved a function-section byte offset to - captured dispatch and Torch-input locations. -- Installed HTTP server exposes the saved artifact, workload index, bundled assets and API. - Headless browser verification passed through workload selection, architecture, compiler - workspace, Monaco and its bundled worker, with no page errors or failed requests. -- Installed local Python factory compilation with full capture and required native analysis - succeeded; this covers the third input path and the full-log mode. - -Scope decisions and remaining research: - -- Snapshot source attribution is implemented. General instruction identity propagation, - metadata preservation through arbitrary optimizations and per-machine-instruction compiler - instrumentation are future research, as in the accepted plan. -- The plugin loads into matching LLVM `opt`; it is not loaded into the stock IREE wheel. - See [native/iree/README.md](native/iree/README.md) for the optional source-build integration. -- Full capture retains all requested logs/resources; standard capture trims display constants - and selects pass snapshots. Both retain authoritative exports in `_full/` for reproducibility. -- Existing browser components and visual design are unchanged. - -## Accepted design - -## 1. Recommended direction - -Build CompilerLens into a **pip-installable compiler inspection tool with a native LLVM provenance pass**. - -The main workflow should be: - -```bash -pip install compilerlens - -compilerlens compile sshleifer/tiny-gpt2 --out runs/gpt2 -compilerlens inspect runs/gpt2 -compilerlens trace runs/gpt2 --module transformer.h.0.mlp.c_fc --to asm -compilerlens view runs/gpt2 -``` - -The terminal exposes the same model structure, stages, IR, diffs, evidence, and lineage that the webpage already presents. `view` serves the existing webpage from the installed package. No new dashboard or experiment screen is required. - -The native contribution is a **real LLVM New Pass Manager analysis/reporting pass written in C++, built with CMake**. It inspects LLVM instructions, debug locations, inline chains, and def-use relationships, then feeds CompilerLens's provenance index. Assembly tracing uses emitted `.file`/`.loc` information. Object-file DWARF address mapping is the first stretch goal. - -The finals story becomes: - -> “CompilerLens lets you inspect a model from the command line and follow compiler-recorded provenance through MLIR, LLVM IR, and assembly. Our C++ LLVM pass exposes structured instruction provenance and audits where information is missing.” - -This is compiler analysis work. It does not need to change model arithmetic or promise a speedup to be technically substantial. - -| Priority | Deliverable | -|---|---| -| Must | Installable CLI exposing existing exploration capabilities | -| Must | C++ LLVM provenance/reporting pass, CMake project, loadable plugin, focused tests | -| Must | Native pass integrated into the CLI's capture/import pipeline | -| Must | Forward and reverse source/assembly lookup for supported x86 assembly dumps | -| Must | Existing webpage opens the same artifact and displays added assembly lineage | -| Stretch 1 | Object address -> DWARF location -> model-operation lookup | -| Stretch 2 | Pass invocation inside a source-built IREE LLVM optimization pipeline | -| Later | General origin propagation across transformations, machine-instruction instrumentation, optimization passes | - -## 2. Exactly what parameters will users pass? - -Separate **creating a run** from **querying a saved run**. Inspection commands should never recompile or download anything. - -### 2.1 `compile`: model -> saved compiler run - -```bash -compilerlens compile sshleifer/tiny-gpt2 \ - --seq-len 16 \ - --cpu host \ - --out runs/gpt2 -``` - -Only the model ID is required. Print resolved defaults in the summary and record them in the manifest. - -| Parameter | Proposed default | Meaning | -|---|---|---| -| `MODEL_ID` | Required for HF input | Hugging Face repository ID using the current supported text-model adapters | -| `--seq-len N` | `16` | Static sequence length used during export; not an inference-time maximum | -| `--revision REF` | Resolve current revision to a commit | Pin the model version; always save the resolved commit | -| `--cpu NAME` | `host` | CPU target for IREE's existing LLVM CPU backend | -| `--out DIR` | Fresh directory under `./compilerlens-runs/` | Persistent outputs; refuse accidental overwrite | -| `--capture standard\|full` | `standard` | Standard keeps current named stages and selected pass snapshots with display trimming; full retains all requested pass dumps and resources | -| `--offline` | Off | Use cached resources; fail clearly if the model is absent | -| `--seed N` | `0` | Reproduce generated example inputs; save actual tensors too | -| `--lineage auto\|required\|off` | `auto` | Auto runs compatible native provenance and reports fallback; required fails if it cannot run; off skips extra lineage analysis | - -`--lineage` is an advanced reproducibility/troubleshooting control. A supported complete installation should use the native path automatically. - -Support the existing built-in examples as mutually exclusive input sources: - -```bash -compilerlens compile --example matmul --out runs/matmul -compilerlens compile --example linear_relu --out runs/linear-relu -``` - -A local Python factory is useful after those paths work: - -```bash -compilerlens compile --python workload.py:build --out runs/local -``` - -Define `build()` to return `(torch_module, example_inputs)`. This executes the user's local Python code. `MODEL_ID`, `--example`, and `--python` must be mutually exclusive. HF-only arguments such as `--seq-len` must not silently alter local-factory inputs. - -For the finals, compilation targets Linux x86-64 CPU. Do not expose unimplemented GPU/device choices. Keep existing advanced IREE controls in the Playground; an optional CLI allowlist can follow the core commands. No tiling/search-budget parameters are needed for this release. - -### 2.2 `inspect`: the webpage's information in the terminal - -```bash -compilerlens inspect runs/gpt2 -compilerlens inspect runs/gpt2 --show architecture -compilerlens inspect runs/gpt2 --show stages -compilerlens inspect runs/gpt2 --show evidence -compilerlens inspect runs/gpt2 --show ops --stage torch-input -compilerlens inspect runs/gpt2 --show ir --stage llvm-optimized --lines 100:140 -compilerlens inspect runs/gpt2 --show ir --stage target-asm -``` - -| Parameter | Meaning | -|---|---| -| `RUN` | Saved run directory or supported artifact JSON | -| `--show summary\|architecture\|stages\|evidence\|ops\|ir` | Existing view to render; default `summary` | -| `--stage ID` | Stage name/ID from `--show stages`; required when an IR/operation query needs disambiguation | -| `--module PATH` | Restrict model-aware results to an existing module mapping | -| `--lines START:END` | Restrict displayed IR to a 1-based line range | -| `--format text\|json` | Human-readable output or structured data; default `text` | - -Use Rich for tables, trees, progress, and source highlighting. Keep output useful without color. Long IR can use the user's pager interactively; piped output should remain plain and unpaginated. Progress/diagnostics go to stderr so JSON stdout stays parseable. - -Stage identifiers come from the run. `inspect --show stages` lists what was actually captured; do not assume every model has the same number of stages. - -### 2.3 `trace`: forward and reverse provenance - -```bash -# Choose an operation from inspect --show ops. -compilerlens trace runs/gpt2 --op OP_ID --to asm - -# Group traces for operations owned by a model module. -compilerlens trace runs/gpt2 --module transformer.h.0.mlp.c_fc --to asm - -# Concrete source selector for the existing matmul fixture. -compilerlens trace runs/matmul --source torch-input:3:10 --to asm - -# Reverse lookup from an emitted assembly listing line. -compilerlens trace runs/matmul --from-stage target-asm --line 106 -``` - -`OP_ID` is a placeholder for a saved operation identifier, not an operation-name guess. Module paths also come from `inspect --show architecture`. - -| Parameter | Meaning | -|---|---| -| `--op ID` | One saved source-operation identity | -| `--module PATH` | Mapped source operations owned by a model module | -| `--source STAGE:LINE:COLUMN` | Explicit location in the run's source/IR anchor | -| `--to llvm\|asm\|all` | Destination view; default `all` | -| `--from-stage ID --line N` | Reverse lookup from a displayed IR/assembly line | -| `--format text\|json` | Human trace or complete provenance records | - -Forward selectors are mutually exclusive; reverse lookup is a separate selector mode. Multiple explicit origins must remain a set instead of becoming one arbitrarily chosen owner. - -Show the evidence chain: - -```text - torch.aten.matmul torch-input:3:10 - -> dispatch source dispatch_0.mlir:18:8 - -> LLVM locations compiler-recorded DILocation references - -> x86 assembly .file/.loc associations at instruction lines - - Relationship: source attribution recorded by the compiler - Missing links: shown explicitly -``` - -Several IR or machine instructions can share one location. This is not a one-to-one instruction ancestry chain. - -### 2.4 Remaining commands - -| Command | Example | Purpose | -|---|---|---| -| `diff` | `compilerlens diff RUN --from STAGE_A --to STAGE_B --mode text` | Existing textual/coarse semantic comparison; enforce compatible tracks | -| `import` | `compilerlens import examples/matmul --out runs/imported` | Build an artifact and run available native/assembly analyses on existing dumps without recompilation | -| `view` | `compilerlens view RUN --port 8000 --open` | Serve the bundled current webpage and selected run locally | -| `doctor` | `compilerlens doctor` | Check native analyzer, IREE, Python dependencies, versions, and supported features | - -`diff --mode text|semantic` reuses current behavior; semantic means the existing operation/dialect summary, not a proof of program equivalence. `view` defaults to loopback and prints its URL; `--open` is optional for remote/headless use. - -An existing-benchmark wrapper, such as `bench RUN --workers 8 --repetitions 10`, is secondary. Before exposing existing benchmarking more broadly, fix its constant-input substitution so decoder attention masks retain their intended values. Do not expand this release into autotuning. - -The ordinary user needs four commands: - -```text -compile MODEL --out RUN create it -inspect RUN understand what was captured -trace RUN --op ID --to asm follow an operation -view RUN inspect it in the existing webpage -``` - -## 3. What the C++ LLVM pass should actually do - -### 3.1 Implement one substantial pass first - -Use **`compilerlens-provenance`** as the name of a module-level New Pass Manager reporting pass. Share its implementation with the packaged native analyzer. - -For each LLVM instruction, emit: - -- snapshot/module identity, function, basic block, and instruction ordinal; -- opcode, result type, operand instruction references, and relevant call information; -- whether it reads/writes memory and basic vector-type information; -- available `DebugLoc`/`DILocation` information: file, directory, line, column, discriminator, scope, and inline call chain; -- whether the location is absent, has line zero, names an external helper, or resolves to a captured dispatch source; -- information needed to connect the instruction to a stable display location and compiler-produced source anchor. - -Use LLVM APIs such as `Module`, `Function`, `Instruction`, `DebugLoc`, `DILocation`, `DIScope`, `DIFile`, and operand/use traversal. This replaces fragile textual metadata interpretation for the native path. - -The pass should be **read-only**, return `PreservedAnalyses::all()`, and leave the executable-producing module unchanged. An analysis/reporting pass is a real compiler pass; changing arithmetic is not a prerequisite. - -### 3.2 Produce a useful provenance audit - -Report distinct measures: - -1. Instructions with compiler debug locations. -2. Instructions whose locations resolve to a captured dispatch source. -3. Instructions whose dispatch source resolves to a Torch-input anchor. -4. Instructions whose anchor has a supported model-module mapping. -5. Reasons for the remaining gaps. - -Separate dispatch functions from runtime/library helpers. An increase in unlocated instructions after library linking is not evidence that model lineage was lost. - -Analyze the existing `codegen`, `linked`, and `optimized` LLVM snapshots. Report coverage changes between checkpoints, but do not identify a specific destructive pass unless that pass was actually instrumented in the same compiler execution. - -Instruction IDs must be snapshot-local: module-content hash, function, block, and ordinal. Similarly named instructions or equal source locations do not establish persistent identity across optimization. - -### 3.3 Separate data dependencies from provenance - -The native pass can expose real def-use edges. They answer “which LLVM values feed this instruction?” They do not establish which source operation created it. - -Do not fill missing provenance by taking operand-origin unions and presenting them as compiler-recorded ownership. Values contribute to later operations, and fusion, rematerialization, common-subexpression elimination, and inlining complicate ownership. - -Arbitrary `!compilerlens.origin` metadata is not guaranteed to survive LLVM optimization or machine-code emission. A general metadata-propagation scheme is a later research task. - -### 3.4 Preserve identity for reliable source joins - -Keep full file identities, columns, inline chains, and explicit origin sets in a native sidecar. Do not key everything by bare line numbers or dispatch basenames. - -The existing architecture mapping remains the bridge from Torch operations to model modules. An LLVM pass cannot reconstruct PyTorch ownership from stripped LLVM IR alone. - -For relocated files, use the run manifest and unique aliases. If captured files share a basename, resolve by full identity/hash or report ambiguity. Never join a C++ helper's line 18 to Torch-input line 18 just because the numbers agree. - -Distinguish **debug source lines** from **physical `.ll` display lines**. A practical solution is for the native analyzer to emit an annotated LLVM text view with instruction-ID comments using LLVM's assembly-printing support. Ingest maps those markers to display lines; preserve the original dump separately. This avoids brittle matching of multiline instructions. - -## 4. How the pass integrates with IREE - -There are three distinct integrations. Describe them accurately in code, documentation, and the presentation. - -### A. Default release: native analysis of actual IREE outputs - -```mermaid -flowchart LR - A[CompilerLens CLI] --> B[Existing Turbine and IREE compilation] - B --> C[LLVM IR, dispatch MLIR, assembly, object] - C --> D[C++ LLVM provenance pass in compilerlens-native] - C --> E[Assembly location resolver] - D --> F[Provenance sidecar and existing artifact] - E --> F - F --> G[Rich CLI queries] - F --> H[Existing webpage] -``` - -The Python package invokes a private executable, `compilerlens-native`. It parses IREE's LLVM output and runs the same New Pass Manager pass used by the developer plugin, writing versioned JSON for ingest. - -This is a native LLVM pass integrated into CompilerLens's pipeline. It is not injected into the IREE wheel's optimizer. It analyzes actual captured IREE output and does not re-optimize a substitute program. - -A private native executable also avoids requiring users to find a compatible system `opt` or load C++ code into IREE's process. - -### B. Developer plugin: conventional LLVM integration - -Build `CompilerLensPasses.so` and load it with the `opt` from the same LLVM build: - -```bash -/path/to/llvm/bin/opt \ - -load-pass-plugin=build/native/CompilerLensPasses.so \ - -passes=compilerlens-provenance \ - -disable-output \ - run/llvm/module.optimized.ll > provenance.json -``` - -The pass name, CMake target, registration callback, and tests provide visible compiler implementation. JSON must be the only stdout output; `-disable-output` prevents bitcode from being mixed into it. - -A plugin built against LLVM 22 is not automatically safe inside LLVM 23. Match the plugin to its host's build/API/ABI settings. Parsing another version's textual IR in a separate process is a different compatibility question that also requires testing. - -### C. Optional invocation inside source-built IREE - -The pinned IREE source constructs its real LLVM optimization pipeline here: - -```text -compiler/plugins/target/LLVMCPU/LLVMIRPasses.cpp - runLLVMIRPasses(...) - PassBuilder - buildPerModuleDefaultPipeline(...) -``` - -Link the pass's reusable core and insert reporting passes around that actual pipeline. Cover O0 too: the current implementation conditionally skips building the optimization pipeline at O0. - -A later extension can register `PassInstrumentationCallbacks` for before/after-pass coverage snapshots. This reports observed coverage changes; per-instruction loss attribution still needs careful identity handling. - -Gate this on an available compatible IREE source/build tree and build time. The pip release should not require every user to rebuild IREE. - -**Do not advertise `iree-compile --load-pass-plugin=...` as working integration.** The inspected binary does not expose that standard `opt` option, and the pinned LLVM pipeline does not register external pass-plugin callbacks. Generic `--load` and IREE compiler plugins are not equivalent to LLVM New Pass Manager registration. - -## 5. Concrete C++ and CMake structure - -```text -native/ - CMakeLists.txt - include/compilerlens/Provenance.h - lib/Provenance.cpp # LLVM traversal, locations, audit - lib/Plugin.cpp # pass registration - tools/compilerlens-native.cpp - tests/ - locations.ll - inline-chain.ll - missing-debug.ll - duplicate-filenames.ll - multiline-instruction.ll - check_reports.py -``` - -For the first plugin milestone, this CMake structure is sufficient: - -```cmake -cmake_minimum_required(VERSION 3.20) -project(CompilerLensNative LANGUAGES C CXX) - -find_package(LLVM REQUIRED CONFIG) -set(COMPILERLENS_LLVM_MAJOR 22 CACHE STRING "Validated LLVM major") -if(NOT LLVM_VERSION_MAJOR EQUAL COMPILERLENS_LLVM_MAJOR) - message(FATAL_ERROR "Use the validated LLVM development toolchain") -endif() - -list(APPEND CMAKE_MODULE_PATH "${LLVM_CMAKE_DIR}") -include(AddLLVM) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -include_directories(SYSTEM ${LLVM_INCLUDE_DIRS}) -include_directories("${CMAKE_CURRENT_SOURCE_DIR}/include") -add_definitions(${LLVM_DEFINITIONS}) - -add_llvm_pass_plugin(CompilerLensPasses - lib/Plugin.cpp - lib/Provenance.cpp -) - -include(CTest) -# Add fixture/report checks using this LLVM build's opt. -``` - -The private executable links the same provenance implementation with LLVM `Core`, `IRReader`, `Analysis`, `Passes`, and `Support`. Use LLVM's CMake helpers to preserve RTTI/exception and link settings. Add `Object`/`DebugInfoDWARF` when implementing address lookup. Install the executable into the native wheel's private binary directory. - -Registration is ordinary New Pass Manager code: - -```cpp -#include "compilerlens/Provenance.h" -#include "llvm/Passes/PassBuilder.h" -#include "llvm/Plugins/PassPlugin.h" // Validated LLVM 22 layout. - -extern "C" LLVM_ATTRIBUTE_WEAK llvm::PassPluginLibraryInfo -llvmGetPassPluginInfo() { - return { - LLVM_PLUGIN_API_VERSION, "CompilerLensPasses", "0.1.0", - [](llvm::PassBuilder &PB) { - PB.registerPipelineParsingCallback( - [](llvm::StringRef Name, llvm::ModulePassManager &MPM, - llvm::ArrayRef) { - if (Name != "compilerlens-provenance") return false; - MPM.addPass(compilerlens::ProvenancePass()); - return true; - }); - } - }; -} -``` - -Older examples use `llvm/Passes/PassPlugin.h`. The available LLVM 22.1.8 uses `llvm/Plugins/PassPlugin.h` and plugin API version 2. Pin to the tested toolchain instead of copying arbitrary tutorial headers. - -```bash -cmake -S native -B build/native -G Ninja \ - -DLLVM_DIR=/path/to/llvm/lib/cmake/llvm \ - -DCMAKE_BUILD_TYPE=Release -cmake --build build/native --parallel 2 -ctest --test-dir build/native --output-on-failure -``` - -Compiler selection and C++ standard-library discovery must be explicit on machines with multiple toolchains. The local smoke test needed Clang's `--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/11`; that is a local build setting, not a flag to hardcode for everyone. - -## 6. Extending lineage through assembly - -### 6.1 First deliverable: emitted assembly listing - -The repository's `.s` files already contain records such as: - -```asm -.file 1 "dumps" "module_main$async_dispatch_0.mlir" -.loc 1 18 8 - ... - vfmadd231ps ... -``` - -The join is: - -```text -assembly instruction line - -> current .loc file/line/column - -> captured dispatch MLIR line - -> original loc(...) in that dispatch dump - -> Torch-input operation - -> model module, where ownership is available -``` - -The current `parse_asm()` discards these directives. Extend it with a conservative location-state parser and feed associations into the existing lineage builder. - -Required details: - -- Support actual numbered `.file` forms emitted by the pinned x86 toolchain, including directory/name forms and escaped filenames. -- Carry `.loc` through ordinary instructions until it changes. -- Treat line zero as unavailable and clear the preceding source association. -- Reset state at relevant function/section boundaries; avoid assigning the previous function's last location to a new prologue. -- Do not clear valid locations at ordinary internal labels. -- Preserve available discriminator/statement metadata in the sidecar. -- Exclude directives, data, labels, and alignment padding from mnemonic-level coverage. -- Report unsupported syntax instead of guessing. - -An inherited `.loc` is the assembler's source association. It does not prove each machine instruction has an independent corresponding LLVM `!dbg` attachment. LLVM-instruction and assembly coverage can therefore differ. - -### 6.2 Stretch: actual object-file addresses - -LLVM IR passes run before instruction selection/register allocation and cannot see final code addresses. Address tracing needs a separate object/debug-info reader. - -Preserve the **actual object emitted by IREE**. Use LLVM Object/DWARF APIs, or validated tools during prototyping, to join: - -```text -(object identity, section, address range) - -> DWARF line-table row / inline frames where present - -> dispatch MLIR source - -> source operation and model module -``` - -Relocatable `.o` files can have address zero in several sections. Preserve section identity and relocation handling. Linked objects have their own addresses; runtime addresses additionally need the correct load mapping. Do not reuse `.o` offsets as VMFB/runtime addresses. - -DWARF rows define ranges, usually ending at the next row within a sequence. Handle `end_sequence`, line zero, duplicate-address rows, and uncovered ranges. Cross-check supported cases with `llvm-dwarfdump` and `llvm-objdump`. - -A later command could be: - -```bash -compilerlens trace RUN --object OBJECT_ID --section SECTION --address 0xOFFSET -``` - -This must not delay the LLVM pass and `.s` tracing. Do not regenerate assembly with an unrelated `llc` and present it as IREE's emitted assembly. - -## 7. Use the existing webpage - -Keep the React screens, navigation, panes, and styling. Retain browser artifact version 0.7 initially. - -The schema already supports assembly stages, instruction line numbers, `Operation.source_loc`, lineage highlight maps, evidence, gap notes, and artifact notes. Populate those fields from the new backend data. - -For a unique resolved origin, set `source_loc`. For multiple explicit origins, retain the set in the sidecar and let the lineage builder add the operation's display line under each supported source anchor without inventing one scalar owner. Leave unresolved associations absent. - -Browser source-line grouping is coarser than the CLI's file/line/column identities. Explain that limit through existing notes. Full native records remain in `provenance.json`, available to CLI queries without a frontend schema migration. - -Packaging requires build/server plumbing: - -1. Bundle existing Vite production assets and Monaco workers. -2. Serve `/artifacts/index.json` and the selected run's artifact from Python. -3. For installed builds, set the existing `VITE_SANDBOX_API` override to an empty string so the existing client uses same-origin routes. Mount API routes before static fallback. -4. Delegate capture/compile API calls to shared services using configurable run paths. -5. Build release assets without copying the entire generated `frontend/public/artifacts` collection into the wheel. - -`view RUN` can open the current landing page with that run listed. New routing, selected-operation deep links, and new UI controls are unnecessary for the finals. - -## 8. Pip packaging, including the native component - -### 8.1 One primary installation - -For the finals, make `pip install compilerlens` the complete supported installation. Include Python CLI/compiler dependencies and a prebuilt native analyzer for the advertised platform. A separate lightweight viewer can follow later. - -Internally, use two distributions if useful: - -```text -compilerlens Python CLI, ingest, adapters, bundled webpage - depends on: -compilerlens-native private executable and native runtime needs -``` - -End users should not need LLVM headers, CMake, Ninja, or system `opt`. Developers building the native package from source need those dependencies. Publish supported platform wheels and test installation using the binary native artifact. - -Start with **Linux x86-64 / Python 3.10**. Broader platforms follow actual build and installation tests. - -### 8.2 Compatibility strategy - -Installed IREE package 3.11.0 reports compiler commit `e4a3b0405d7d23554da26403658d0e8c3c5ecf25` and LLVM 23.0.0git. The development installation used for the pass smoke test is LLVM 22.1.8. - -LLVM 22.1.8 successfully parsed the tested IREE textual dumps. This does not establish general forward IR compatibility. Pin and test the exact producer/analyzer combination. Reject unsupported IR or rejected debug information; do not strip unfamiliar attributes just to make parsing succeed. - -The standalone analyzer does not share an ABI with IREE. The plugin must be loaded by its matching LLVM 22.1.8 `opt`; an in-IREE build needs IREE's own exact LLVM dependency/build settings. - -Version the JSON protocol. Record producer compiler, analyzer LLVM, pass version, and input hashes. Reject incompatible sidecar versions clearly. - -### 8.3 Release work - -- Add `pyproject.toml`, the console entry point, and a `compilerlens` namespace. Avoid installing generic top-level `models`, `backend`, and `ingest` packages. -- Reuse current modules under that namespace; make the import/path move early and avoid unrelated refactoring. -- Share capture/artifact services between CLI and FastAPI. -- Read installed assets through `importlib.resources`; write runs to `--out`, never into `site-packages`. -- Keep direct dependency metadata separate from the pinned development environment snapshot. -- `requirements.txt` uses a CPU PyTorch extra index. Package metadata cannot copy that pip index directive. Test the default resolver and document CPU-only preinstallation if needed. The complete install may be large; do not promise a small download. -- Build native wheels in a suitable Linux release environment. Inspect dependencies and RPATHs; do not ship references to this machine's `/pkg/qct/software/...` paths. -- Include required LLVM/third-party license notices and native runtime libraries in the release process. -- Check package names on Day 1 and produce local installable wheels even if publication is pending. Advertise public pip installation only once the distributions exist. - -## 9. Run layout and implementation map - -```text -runs/run-id/ - manifest.json versions, commands, model, target, input identities - artifact.json existing browser contract - provenance.json richer native/assembly relationships - source/ source anchor and module ownership - mlir/ existing stage captures - llvm/ original .ll, .s, dispatch source, retained .o - native/ annotated views and per-snapshot pass reports - inputs/ actual example tensors - logs/ -``` - -Preserve exact dispatch-source text referenced by LLVM and assembly. Reformatting/trimming that authoritative file can change line numbers and corrupt joins. Separate display copies can be trimmed with explicit mapping back to authoritative coordinates. - -| Existing location | Planned change | -|---|---| -| `scripts/compile_hf_model.py` | Put orchestration behind `compile`; generate artifacts automatically | -| `backend/compiler/runner.py` | Capture debug artifacts; invoke native analyzer after compilation | -| `native/` | New C++ pass, plugin, native driver, CMake, fixtures | -| `ingest/llvm_parser.py` | Add `.file`/`.loc` associations; retain current metadata parser as explicit fallback | -| `ingest/build.py` | Join native and assembly reports into existing stages/evidence | -| `ingest/lineage.py` | Validate source identities and accept explicit origin sets while retaining browser-compatible output | -| `ingest/workloads/generated.py` | Retain actual executable/function/file relationships for traces rather than using an unrelated representative kernel | -| `models/architecture.py` | Reuse exact ownership capture; local-factory support only if time permits | -| `backend/api/` | Serve packaged assets/runs; share capture services | -| `frontend/` | Release build configuration only; existing UI and interactions remain | - -Hash authoritative files. Type relationships explicitly: compiler source association, inline call site, data dependency, or unresolved. Do not use instruction IDs as cross-snapshot identities. - -## 10. What was actually tested for this direction - -These were feasibility checks in temporary files, not completed product features: - -1. **CMake/plugin build worked.** A small C++ New Pass Manager reporting pass was built with LLVM/Clang 22.1.8, CMake 3.31.5, and Ninja. It exports `llvmGetPassPluginInfo`, registers a module pass, and returns `PreservedAnalyses::all()`. -2. **Plugin loading worked.** Matching `opt` loaded it and ran it on matmul and tiny GPT-2 optimized LLVM dumps. -3. **Real instruction metadata was accessible.** The small audit counted 1,697 instructions / 108 with nonzero debug lines in the existing matmul optimized dump, and 3,702 / 1,492 in tiny GPT-2. These are whole-dump instruction/debug counts, not model-origin coverage or lineage-quality scores. -4. **Fresh IREE dumps were readable.** The pass parsed newly captured matmul codegen, linked, and optimized textual LLVM files. -5. **Assembly provenance exists.** The existing matmul's `vfmadd231ps` at listing line 106 resolves through dispatch source line 18 to Torch-input `3:10` in a small exploratory resolver. This is a checked example, not validated general assembly coverage. -6. **Object DWARF exists.** Fresh IREE compilation with executable debug level 3 and CPU debug symbols produced an object and linked executable with readable DWARF line tables. LLVM tools resolved addresses to dispatch source. -7. **The IREE integration boundary was checked.** Its exact LLVM pipeline source constructs its own `PassBuilder` and does not automatically install external pass-plugin callbacks. - -Temporary code/build/artifacts are under `/tmp/compilerlens-native-plan-check`. No C++ product or frontend implementation was added to the repository during planning. Portable wheels, the full provenance pass, CLI, and general assembly resolver remain implementation work. - -## 11. Five-day schedule - -Assume 6–7 focused hours per contributor per day. Keep one native pass plus its CLI adapter on the critical path, and reserve the final afternoon for rehearsal. - -| Day | Compiler/native owner | CLI/package owner | Required result | -|---|---|---|---| -| **1: Interfaces and builds** | Reproduce pass build; define JSON; validate IR compatibility; native-driver skeleton | Freeze CLI help/arguments; namespace/import move; capture/import and summary; installed viewer skeleton | C++ pass runs on real IR; installed CLI captures/imports a run | -| **2: Lineage bridge** | Debug/inline/file records; source joins; assembly location state; negative fixtures | Rich architecture/stages/evidence/IR output; manifest; native invocation and diagnostics | Known matmul source traces to emitted assembly | -| **3: Complete native workflow** | Native executable sharing pass core; stable display mapping; audit reasons; reverse lookup | Trace, diff, doctor, view; native wheel assembly; enrich existing artifact schema | Native CLI and unchanged webpage consume the same run | -| **4: Validate and package** | Second workload; unknown/ambiguous/inline cases; LLVM-tool comparisons; address stretch only if core is green | Clean install outside checkout; runtime dependency checks; offline assets; browser regression | Installable wheels and reproducible two-workload demo | -| **5: Freeze and present** | Fix blocking provenance issues; freeze artifacts/toolchain | Final help/examples; offline demo; local wheel backup; recording/rehearsal | Repeatable CLI -> C++ pass evidence -> assembly -> current webpage demo | - -### Scope gates - -- **Day 1:** prove the native-driver skeleton can be packaged without the machine's LLVM paths. Native portability cannot wait until Day 5. -- **Day 2:** finish `.s` tracing before object-address lookup. Available DWARF data does not make the complete address feature mandatory. -- **Day 3:** if an IREE source build is not already working, ship standalone native-pass integration and matched-`opt` plugin. Document the internal hook without claiming it shipped. -- **Day 4:** cut local-factory support, object-address lookup, and cosmetic CLI work before native tests, source-identity checks, or wheel tests. -- **Solo version:** HF/built-in capture, summary/IR inspection, one native reporting pass, one assembly trace, existing browser. Reduce optional CLI views and stretch features. - -## 12. Tests and acceptance criteria - -### Native pass - -- Check file/line/column/inline-chain reports against LLVM fixtures. -- Cover missing metadata, line zero, helper scopes, duplicate basenames, and shared locations. -- Check def-use edges without converting them into origin claims. -- Test multiline instructions and exact displayed-IR mapping. -- Compare normalized IR before/after reporting to confirm no module mutation. -- Reject incompatible IR/debug information and analyzer protocols explicitly. - -### Assembly/source resolver - -- Cover numbered `.file`, directory/name forms, inheritance, line-zero resets, internal labels, and function boundaries. -- Cover multiple files/functions, missing source files, and ambiguous aliases. -- Verify the existing matmul FMA association and a tiny-model case. -- Check forward/reverse lookup agreement. -- For optional address lookup, check section-aware ranges and `end_sequence` against LLVM tools. - -### Package and product - -- Install wheels in a new environment outside the repo with no project `PYTHONPATH` and no system LLVM on PATH. -- Run capture/import, inspect, trace, and view from that installation. -- Confirm JSON is uncontaminated by progress text and saved runs survive process restart. -- Open old and enriched artifacts in the existing webpage, including assembly highlighting. -- Confirm offline static assets/Monaco and cached-model compilation. -- Record capture time, analysis time, and artifact size. Make no performance-improvement claim for an analysis feature. - -Success means the complete chain works with honest gaps. Lower coverage after removing false associations can be a correctness improvement. “100% lineage” is not a release criterion. - -## 13. Why this is credible compiler work - -The implementation provides: - -1. A conventional LLVM pass, build targets, registration, and tests. -2. Native instruction/metadata traversal and a versioned provenance representation. -3. Def-use analysis distinguished from source attribution. -4. A join through dispatch MLIR into model ownership. -5. Assembly/debug-info handling, gap audits, and false-link regression fixtures. -6. A compatible native component packaged as a usable developer tool. - -LLVM already supplies source debugging, symbolization, and debug-info validation. We are not inventing DWARF or claiming locations prove semantic equivalence. CompilerLens contributes the model-to-compiler bridge, combined provenance queries, and consistent CLI/web artifact workflow. - -A location-auditing **MLIR pass** or **machine-function analysis pass** could follow. MLIR exposes richer fused/callsite locations earlier; machine passes see post-selection instructions. Both add build/pipeline integration work. Start with the tested LLVM pass and assembly evidence, then expand where measured gaps justify it. - -## 14. Finals demonstration - -Use roughly six minutes: - -1. Show `--help`, `doctor`, and the four-command workflow using cached dependencies. -2. Inspect model architecture, stages, and a source operation in terminal output. -3. Briefly show CMake, pass registration, and the LLVM APIs used in the C++ implementation. -4. Trace a matmul source through dispatch/LLVM evidence to an emitted FMA instruction. -5. Reverse-query that assembly line; show an unavailable/ambiguous case handled correctly. -6. Run `view RUN` and inspect the same evidence through the existing webpage. - -If address tracing is complete, add it as the technical extension. If in-IREE invocation is incomplete, accurately describe the shipped path as a native LLVM pass over IREE's captured IR, integrated into CompilerLens. - -Suggested pitch: - -> “We made CompilerLens installable and scriptable, added a C++ LLVM provenance pass, and extended source attribution into assembly. The CLI and webpage inspect the same compiler evidence.” - -## 15. References - -Reviewed [README.md](README.md), [architecture notes](../dummy/ARCHITECTURE-NOTES.md), [original design](../dummy/DESIGN-DOC.md), the compiler runner, architecture capture, LLVM/assembly parsers, lineage builder, artifact schema, frontend clients, and generated matmul/tiny-model dumps. - -External references: - -- [Writing an LLVM New Pass Manager pass](https://llvm.org/docs/WritingAnLLVMNewPMPass.html) -- [LLVM source-level debugging](https://llvm.org/docs/SourceLevelDebugging.html) -- [Updating LLVM debug information](https://llvm.org/docs/HowToUpdateDebugInfo.html) -- [llvm-dwarfdump](https://llvm.org/docs/CommandGuide/llvm-dwarfdump.html) -- [llvm-symbolizer](https://llvm.org/docs/CommandGuide/llvm-symbolizer.html) -- [Pinned IREE LLVM pipeline](https://github.com/iree-org/iree/blob/e4a3b0405d7d23554da26403658d0e8c3c5ecf25/compiler/plugins/target/LLVMCPU/LLVMIRPasses.cpp) -- [Pinned IREE LLVM CPU target/artifact emission](https://github.com/iree-org/iree/blob/e4a3b0405d7d23554da26403658d0e8c3c5ecf25/compiler/plugins/target/LLVMCPU/LLVMCPUTarget.cpp) -- [Pinned IREE CPU target options](https://github.com/iree-org/iree/blob/e4a3b0405d7d23554da26403658d0e8c3c5ecf25/compiler/plugins/target/LLVMCPU/LLVMTargetOptions.cpp) - -Installed compiler help, LLVM headers, an actual native build, and emitted artifacts established local feasibility. Live documentation, arbitrary LLVM tutorials, and the installed IREE wheel do not necessarily expose interchangeable APIs. diff --git a/MANIFEST.in b/MANIFEST.in index ca6917c..457022d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include README.md pyproject.toml setup.py -recursive-include native *.cpp *.h *.ll *.py *.txt *.md *.cmake *.patch +recursive-include llvm *.cpp *.h *.ll *.py *.txt *.md *.cmake *.patch recursive-include scripts *.py *.mjs recursive-include frontend src/* include frontend/package.json frontend/package-lock.json frontend/index.html frontend/vite.config.ts frontend/vite.release.config.ts frontend/tsconfig.json diff --git a/README.md b/README.md index 9d60a08..df87a11 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,6 @@ CompilerLens is an interactive explorer for AI compiler pipelines. It compiles a Hugging Face model through IREE and presents the resulting Torch, MLIR, LLVM IR, and x86-64 stages in one navigable interface. -## CLI and native LLVM pass - -The `feature/experiments-cli` branch adds a pip-installable CLI, the bundled existing viewer, -a C++ LLVM provenance pass/plugin, source-to-assembly tracing, and object/DWARF lookup. - -```bash -python -m pip install dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl \ - --extra-index-url https://download.pytorch.org/whl/cpu -compilerlens doctor -compilerlens compile --example matmul --out runs/matmul --lineage required -compilerlens inspect runs/matmul -compilerlens trace runs/matmul --module model --to asm -compilerlens view runs/matmul -``` - -The local wheel targets Linux x86-64 (validated on glibc 2.35/Python 3.10); it has not been -published to PyPI. See [CLI parameters, native development and release instructions](docs/CLI.md) -and the [implementation review](FINALS_5_DAY_PLAN.md). The Python implementation lives under -`compilerlens/`; checkout compatibility imports preserve the older developer commands below. -

CompilerLens landing page with Hugging Face model search, the MLIR lowering pipeline, and Compiler Playground

@@ -35,11 +15,12 @@ and the [implementation review](FINALS_5_DAY_PLAN.md). The Python implementation - [Key Capabilities](#key-capabilities) - [System Architecture and Repository Layout](#system-architecture-and-repository-layout) - [System Requirements](#system-requirements) -- [Installation](#installation) +- [CLI Quick Start](#cli-quick-start) +- [Installation from Source](#installation-from-source) - [Running CompilerLens Locally](#running-compilerlens-locally) - [Compiling Models from the Web Interface](#compiling-models-from-the-web-interface) - [Supported Model Architectures](#supported-model-architectures) -- [Command-Line Model Compilation](#command-line-model-compilation) +- [Repository Compilation Script](#repository-compilation-script) - [Compiler Playground and Benchmarking](#compiler-playground-and-benchmarking) - [Validation and Testing](#validation-and-testing) - [Troubleshooting](#troubleshooting) @@ -53,9 +34,11 @@ and the [implementation review](FINALS_5_DAY_PLAN.md). The Python implementation - An interactive model architecture map linked to the operations each module exports - Side-by-side textual and semantic diffs - Compiler evidence linked directly to the IR that produced it -- Metadata-backed operation lineage from framework-level operations through LLVM IR +- Compiler-recorded operation lineage from framework-level operations through LLVM IR and assembly - A landing-page search flow that compiles a Hugging Face model and adds it as a workload - A Compiler Playground for changing selected compiler options and benchmarking the result +- An installable CLI for capturing, inspecting, tracing, comparing, and benchmarking saved runs +- A bundled browser viewer for opening CLI captures without a separate frontend setup ## System Architecture and Repository Layout @@ -68,48 +51,58 @@ the TypeScript frontend. ```mermaid %%{init: {"theme":"base","themeVariables":{"background":"#080c12","primaryTextColor":"#e5e7eb","lineColor":"#64748b","fontFamily":"ui-monospace, SFMono-Regular, Menlo, monospace","clusterBkg":"#0b111b","clusterBorder":"#334155","edgeLabelBackground":"#111827"},"flowchart":{"curve":"linear","nodeSpacing":36,"rankSpacing":48}}}%% flowchart TB - subgraph Entry["1 · User entry points"] + subgraph Entry["1 · CLI and web entry points"] direction LR - Search["Landing page
Search and compile a model"] - Controls["Compiler Playground
Choose model, stage, and flags"] + CLI["CLI · compile
HF model · packaged example · Python factory"] + Import["CLI · import
Existing compiler dumps"] + Search["Web · Model search
Compile and explore a Hugging Face model"] + Controls["Web · Compiler Playground
Choose model, stage, and flags"] end - subgraph Service["2 · FastAPI orchestration"] + subgraph Service["2 · Shared services and web orchestration"] direction LR - Explore["Persistent exploration job
POST /explore · poll GET /compile/{id}"] - Compile["Temporary compilation job
POST /compile · poll GET /compile/{id}"] + Explore["FastAPI · Explore job
POST /explore"] + Shared["Shared capture service
Parameters · progress · run manifest"] + Compile["FastAPI · Playground job
POST /compile"] end - subgraph Preparation["3 · Shared model preparation and export"] + subgraph Preparation["3 · Model preparation and export"] direction TB - Resolve["Hugging Face Hub or local cache
Resolve revision and architecture"] - Wrap["PyTorch model adapter
Load float32 weights · wrap output · build inputs"] + Resolve["Hugging Face Hub or cache
Resolve revision · load model · build inputs"] + Local["Packaged example or Python factory
Build module and example tensors"] Export["PyTorch export + IREE Turbine AOT
Capture module ownership · emit Torch MLIR"] - Mode{"Compilation mode"} + Mode{"Capture or Playground?"} - Resolve --> Wrap --> Export --> Mode + Resolve --> Export + Local --> Export + Export --> Mode end - subgraph Compilation["4 · Compilation, storage, and visualization"] + subgraph Compilation["4 · IREE compilation, analysis, and results"] direction LR - subgraph Persistent["Persistent exploration path"] + subgraph Persistent["Saved capture · CLI and web Explore"] direction TB - Capture["Full IREE pipeline capture
iree-compile + iree-opt"] - Dumps[("Compiler dump directory
examples/{model}/
MLIR · pass logs · LLVM IR · assembly")] - Analyze["Artifact construction
Parse · normalize · architecture · lineage · diff"] - Artifacts[("Static artifact store
frontend/public/artifacts/
model JSON · index.json")] - Explorer["Exploration interface
Architecture map · Pipeline · Lineage"] - - Capture --> Dumps --> Analyze --> Artifacts --> Explorer + Capture["IREE pipeline capture
iree-compile + iree-opt · debug information"] + Dumps[("Captured compiler files
MLIR · pass logs · LLVM IR · assembly · objects")] + Analyze["LLVM analysis + Python artifact construction
Instruction reports · source joins · architecture · diffs"] + Run[("Self-contained run
Artifacts · lineage · saved tensors · versions and hashes")] + Terminal["CLI · inspect / trace / diff
Terminal tables, IR, and JSON"] + SavedBenchmark["CLI · bench
IREE runtime + saved input tensors"] + Explorer["Browser · CLI view or web library
Architecture · Pipeline · Operation Lineage"] + + Capture --> Dumps --> Analyze --> Run + Run --> Terminal + Run -->|view or web publication| Explorer + Run -.-> SavedBenchmark -.-> Terminal end - subgraph TemporaryPath["Compiler Playground path"] + subgraph TemporaryPath["Compiler Playground · selected stages"] direction TB - Selected["Focused IREE compilation
Compile only the requested stage"] - Temporary[("Temporary work directory
Selected MLIR + executable VMFB")] + Selected["Focused IREE compilation
Selected IR stage + executable VMFB"] + Temporary[("Job files · jobs/job-id/
Selected IR · VMFB · benchmark inputs")] JobState[("In-memory job state
Status · signals · output paths")] - Benchmark["Optional runtime benchmark
iree-benchmark-module"] + Benchmark["Optional web benchmark
iree-benchmark-module"] Results["Playground interface
Generated IR · signals · timing"] Selected --> Temporary --> JobState --> Results @@ -117,19 +110,22 @@ flowchart TB end end - Search --> Explore + CLI --> Shared + Search --> Explore --> Shared Controls --> Compile - Explore --> Resolve + Shared --> Resolve + Shared --> Local Compile --> Resolve - Mode -->|Full pipeline| Capture - Mode -->|Selected stage| Selected - - class Search,Controls,Explorer,Results ui - class Explore,Compile api - class Resolve,Wrap model - class Export,Capture,Selected,Benchmark compiler + Import -->|copy existing dumps into a new run| Dumps + Mode -->|CLI or web Explore| Capture + Mode -->|Web Playground| Selected + + class CLI,Import,Search,Controls,Terminal,Explorer,Results ui + class Explore,Shared,Compile api + class Resolve,Local model + class Export,Capture,Selected,Benchmark,SavedBenchmark compiler class Mode decision - class Dumps,Artifacts persistent + class Dumps,Run persistent class Temporary,JobState transient class Analyze analysis @@ -150,33 +146,34 @@ flowchart TB style TemporaryPath fill:#1d180b,stroke:#735d16,stroke-width:1px,color:#fde68a ``` -Blue nodes are browser surfaces, violet nodes are API orchestration, orange nodes are shared -model preparation, pink nodes are compiler execution, green nodes are durable files, and amber -nodes are temporary state. Both API routes converge on the same model preparation code before -branching into either complete artifact capture or fast selected-stage compilation. - -The persistent landing-page flow performs the following steps: - -1. `models/detect.py` resolves the Hugging Face revision and determines the supported model - signature. -2. `models/hf_wrapper.py` loads the model, normalizes its inputs, and exposes a traceable tensor - output. -3. `backend/compiler/runner.py` exports the PyTorch module once, records exact module ownership - from `torch.export` metadata, and invokes `iree-compile` and `iree-opt` to capture the - lowering pipeline. -4. The raw stage, pass, LLVM IR, and assembly dumps are written under `examples//` for - persisted compilations. -5. `ingest/` parses those dumps and joins model modules to Torch operations, stage metadata, - diffs, compiler evidence, and operation lineage. -6. The resulting artifact and workload index are written to `frontend/public/artifacts/`. -7. The React frontend first renders the model architecture, then lets each mapped module open - its compiler lineage or the complete Monaco and Golden Layout workspace. - -The Compiler Playground deliberately stops short of artifact construction. It exports the same -wrapped model, compiles only the requested stage into a temporary directory, retains a VMFB for -optional benchmarking, and exposes status, signals, IR paths, and timing through in-memory job -state. These Playground jobs disappear when the API process restarts; persisted exploration -artifacts do not. +Blue nodes are user-facing CLI and browser surfaces; violet nodes coordinate work; +orange nodes prepare models; pink nodes execute compiler or runtime tools; teal nodes +analyze compiler output; green nodes store saved captures; amber nodes hold Playground +files and job state. Solid arrows show the main flow; dashed arrows show optional benchmarking. + +The CLI and web model search use the same capture service: + +1. Choose a Hugging Face model, or use a packaged example or local Python factory through the CLI. +2. Prepare the model and actual input tensors, resolve the model revision, and export through + PyTorch and IREE Turbine while capturing model-module ownership. +3. Run IREE and retain MLIR stages, pass logs, LLVM snapshots, assembly, and object files. +4. Analyze LLVM snapshots with the C++ pass and join compiler source locations in Python. + Build architecture mappings, operation lineage, diffs, and the browser artifact. See the + [LLVM component guide](llvm/README.md) for the analysis details and coverage limits. +5. Save a self-contained run. CLI captures use `--out` or a fresh directory under + `compilerlens-runs/`; web Explore uses `runs//` in the configured workspace. +6. Inspect or trace the saved run in the terminal, benchmark its captured inputs, or open it + in the existing browser viewer with `compilerlens view`. Web Explore also publishes its + artifact and workload entry to the browser library under `frontend/public/artifacts/`. + +`compilerlens import` starts from existing dumps and builds a new analyzed run without +recompiling the model. The static developer workflow can also build browser artifacts from +existing dumps using `npm run artifact`. + +The Compiler Playground exports the selected Hugging Face model and compiles requested stages +into `jobs//`. It exposes IR, compiler signals, and optional timings directly through +job state. Restarting the API loses that in-memory state; generated files remain on disk. +The saved-capture path retains artifacts that remain available in the workload library. ### Model architecture to compiler bridge @@ -222,7 +219,7 @@ Each workload is represented by one normalized JSON document containing: - Model hierarchy, tensor shapes, parameter counts, and module-to-compiler mappings - Explicit notes for incomplete or unavailable compiler data -The Python schema is defined in `ingest/schema.py` and mirrored by +The Python schema is defined in `compilerlens/ingest/schema.py` and mirrored by `frontend/src/api/artifact.ts`. A schema change must update both definitions and increment the artifact version. @@ -232,64 +229,133 @@ artifact version. |---|---|---:|---|---| | Static explorer | `npm run artifact` + `npm run dev` | No | Generated artifact files | Explore existing compiler dumps | | Web model search | Model search field on the landing page | Yes | Dumps and artifact are retained | Add a Hugging Face model end to end | -| Compiler Playground | **Open the Compiler Playground** | Yes | In-memory job and temporary files | Test compiler options and selected stages | -| Command-line compilation | `scripts/compile_hf_model.py` | No | Dumps under `examples/` | Scriptable or offline compilation | +| Compiler Playground | **Open the Compiler Playground** | Yes | Job files; status in memory | Test compiler options and selected stages | +| CLI capture and analysis | `compilerlens compile` / `import` / `inspect` / `trace` | No | Self-contained run directory | Capture, query, compare, and benchmark models | +| Bundled viewer | `compilerlens view RUN` | Started by the command | Reads saved runs | Explore CLI captures in the browser | +| Repository compilation script | `scripts/compile_hf_model.py` | No | Dumps under `examples/` | Generate dumps for the static developer workflow | ### Repository layout ```text CompilerLens/ -├── backend/ -│ ├── api/ -│ │ ├── app.py Compilation, exploration, artifact, and benchmark endpoints -│ │ └── run_server.py API startup and toolchain validation -│ ├── compiler/ -│ │ └── runner.py Turbine export and multi-stage IREE compilation -│ └── measure/ Runtime benchmarking and controlled comparisons -├── models/ -│ ├── detect.py Hugging Face metadata and architecture detection -│ ├── architecture.py Exact torch.export module and operation capture -│ ├── hf_wrapper.py Traceable model wrapper and example inputs -│ └── prefetch.py Model caching for offline demonstrations -├── ingest/ -│ ├── build.py Artifact and landing-page index generation -│ ├── architecture.py Sidecar loading, fallback topology, and lineage enrichment -│ ├── schema.py Python definition of the artifact contract -│ ├── lineage.py Source-location-based operation lineage -│ ├── mlir_parser.py MLIR operation and dialect extraction -│ ├── llvm_parser.py LLVM summaries and debug-metadata lineage -│ ├── pass_log.py Per-pass snapshot extraction -│ └── workloads/ Static and generated workload specifications -├── frontend/ -│ ├── src/ -│ │ ├── api/ Artifact and live API clients -│ │ ├── components/ Shared viewers, timelines, and controls -│ │ ├── panes/ Dockable workspace panes -│ │ └── ArchitectureExplorerPage.tsx Interactive model-to-compiler map -│ ├── public/artifacts/ Generated artifacts served by Vite -│ ├── scripts/verify.mjs Browser-level regression suite -│ ├── package.json Frontend commands and dependencies -│ └── vite.config.ts Development server configuration -├── scripts/ -│ └── compile_hf_model.py Command-line Hugging Face compilation -├── docs/images/ Screenshots used by this documentation -├── examples/ Source programs and compiler dump directories -├── requirements.txt Pinned Python environment -└── README.md Setup, usage, and contribution guide +├── compilerlens/ Installable Python implementation +│ ├── cli.py compile/import/inspect/trace/diff/bench/view/doctor +│ ├── services.py Shared capture and import orchestration +│ ├── lineage.py LLVM report processing and source/assembly joins +│ ├── queries.py Queries over saved runs +│ ├── storage.py Durable run files and workspace selection +│ ├── server.py Serve the bundled viewer and API +│ ├── backend/ API, IREE compiler runner and benchmarking +│ ├── models/ Hugging Face detection, loading and architecture capture +│ ├── ingest/ Artifact construction, parsers and operation lineage +│ └── examples/ Packaged matmul, linear/ReLU and transformer examples +├── llvm/ C++ LLVM analysis, plugin, driver, tests and IREE hook +├── backend/, models/, ingest/ Compatibility imports for checkout commands +├── frontend/ React viewer, build configuration and browser checks +├── scripts/ Legacy model compiler, release build and install checks +├── docs/ CLI guide, release checklist and screenshots +├── examples/ Existing fixtures and legacy script output +├── runs/ Generated CLI/web captures (not tracked) +├── tests/ Python regression tests +├── pyproject.toml Package metadata and runtime dependencies +├── requirements.txt Pinned development environment +└── README.md Project overview and setup ``` ## System Requirements -- Linux x86-64 -- Python 3.10 -- Node.js 20.19+ or 22.12+; Node 22 is recommended -- Internet access for the initial dependency installation and uncached Hugging Face models +- Linux x86-64; the tested package wheel requires glibc 2.35 or newer +- Python 3.10 is the validated interpreter +- Internet access for installation and uncached Hugging Face models - Enough disk space for compiler dumps; even small models can produce tens or hundreds of MB +- For frontend development from source: Node.js 20.19+ or 22.12+; Node 22 is recommended + +The package includes the IREE compiler/runtime dependencies and a prebuilt LLVM analyzer. +The installed CLI and bundled viewer do not require Node.js, CMake, or an LLVM SDK. Building +that analyzer from source requires the tools listed in the [CLI development guide](docs/CLI.md#builddevelop-the-native-pass). + +## CLI Quick Start + +Use the installed CLI to capture a model, inspect its compiler stages, and open the same +browser viewer. A repository checkout is not required. + +### Create an environment and install + +Version `0.1.0` is available on **TestPyPI** and has been tested on Linux x86-64 with +Python 3.10 and glibc 2.35+. Production PyPI publication and installation testing are pending, +so use the TestPyPI steps below for now. + +```bash +mkdir compilerlens-demo +cd compilerlens-demo +python3.10 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip + +# Download CompilerLens from TestPyPI. +python -m pip download --no-deps --only-binary=:all: \ + --index-url https://test.pypi.org/simple/ \ + --dest wheels compilerlens==0.1.0 + +# Install the wheel and resolve its dependencies from the normal indexes. +python -m pip install \ + --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + wheels/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl + +python -m pip check +compilerlens doctor +``` + +Downloading the package separately keeps TestPyPI out of dependency resolution. The PyTorch +CPU index supplies CPU builds. Once a production release is published, installation can use +`python -m pip install compilerlens` with the documented dependency indexes. + +### Compile, inspect, and open the viewer + +Start with the packaged matmul example, which needs no Hugging Face model download: + +```bash +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens inspect runs/matmul +compilerlens inspect runs/matmul --show stages +compilerlens trace runs/matmul --module model --to asm +compilerlens view runs/matmul --port 8000 +``` + +Open `http://127.0.0.1:8000` to explore the captured model architecture, compiler pipeline, +and operation lineage. Stop the viewer with Ctrl+C. Choose a new output directory for each +capture; CompilerLens refuses to overwrite an existing run. + +To capture a Hugging Face model: + +```bash +compilerlens compile sshleifer/tiny-gpt2 --seq-len 8 --out runs/tiny-gpt2 +``` + +### Primary commands + +| Command | Purpose | +|---|---| +| `compilerlens doctor` | Check compiler tools, the LLVM analyzer, and bundled viewer assets | +| `compilerlens compile` | Capture a Hugging Face model, packaged example, or local Python factory | +| `compilerlens import DUMPS --out RUN` | Turn existing compiler dumps into a saved run | +| `compilerlens inspect RUN` | Inspect architecture, stages, evidence, operations, IR, or object sections | +| `compilerlens trace RUN` | Follow module/source associations into LLVM and assembly, or query them in reverse | +| `compilerlens diff RUN --from STAGE --to STAGE` | Compare compatible stages within a run | +| `compilerlens bench RUN` | Benchmark with the saved input tensors | +| `compilerlens view RUN` | Serve the saved run in the bundled browser viewer | + +See the **[detailed CLI guide](docs/CLI.md)** for selectors, JSON output, offline capture, +local Python factories, object-address lookup, and build instructions. The +[release checklist](docs/RELEASING.md) records TestPyPI validation and publication steps. +The tested `0.1.0` wheel predates the latest source-directory and terminology changes; +those changes will ship in a subsequent release. -The pinned Python dependencies include the IREE compiler/runtime tools and CPU-only PyTorch. -No separate LLVM or IREE installation is required. +## Installation from Source -## Installation +Use this workflow to develop CompilerLens or run the frontend directly from the checkout. +For the packaged CLI and viewer, follow the [CLI quick start](#cli-quick-start). From the repository root: @@ -377,9 +443,10 @@ Vision, audio, multimodal, and encoder-decoder models need additional input wrap rejected instead of being compiled with an incorrect signature. Sequence length is fixed at compile time; the website uses 16 tokens for a compact first run. -## Command-Line Model Compilation +## Repository Compilation Script -To compile without the landing-page UI: +For the installed package, use the [CLI quick start](#cli-quick-start). The repository also +provides a developer script for generating dumps and rebuilding the static artifact library: ```bash source .venv/bin/activate @@ -410,13 +477,14 @@ Choose **Open the Compiler Playground** from the landing page to: - Inspect the resulting IR and compiler signals - Run an explicit whole-model benchmark -The model selector combines a small set of baseline models with every successful persisted -compilation found under `examples/*/model_info.json`. Reopening the Playground refreshes this list, +The model selector combines a small set of baseline models with persisted captures found under +`examples/*/model_info.json` and `runs/*/model_info.json`. Reopening the Playground refreshes this list, so models added through landing-page search or the command-line compiler appear automatically. Playground jobs are stored in memory and disappear when the API restarts. Hugging Face models -compiled through landing-page search are persisted under `examples/` and remain available after -artifact regeneration. +compiled through landing-page search are persisted under `runs//`, with viewer artifacts +published to `frontend/public/artifacts/`. Installed builds use the user cache workspace by +default; `COMPILERLENS_WORKSPACE` overrides it. ## Validation and Testing @@ -424,7 +492,7 @@ Run the backend syntax checks and frontend production build: ```bash source .venv/bin/activate -python -m py_compile backend/api/app.py backend/api/run_server.py ingest/build.py ingest/schema.py models/architecture.py ingest/architecture.py +python -m py_compile compilerlens/backend/api/app.py compilerlens/backend/api/run_server.py compilerlens/ingest/build.py compilerlens/ingest/schema.py compilerlens/models/architecture.py compilerlens/ingest/architecture.py python -m unittest discover -s tests -v cd frontend diff --git a/compilerlens/__init__.py b/compilerlens/__init__.py index 6b5458e..6d850bc 100644 --- a/compilerlens/__init__.py +++ b/compilerlens/__init__.py @@ -1,4 +1,4 @@ -"""CompilerLens: model-aware compiler inspection and provenance.""" +"""CompilerLens: model-aware compiler inspection and lineage.""" __version__ = "0.1.0" diff --git a/compilerlens/backend/api/app.py b/compilerlens/backend/api/app.py index 06cde6e..1f2b872 100644 --- a/compilerlens/backend/api/app.py +++ b/compilerlens/backend/api/app.py @@ -246,7 +246,7 @@ def explore_model(request: ExploreRequest, background: BackgroundTasks): def _run_explore(job_id: str, model_id: str, seq_len: int) -> None: - """Use the same durable capture and native provenance pipeline as the CLI.""" + """Use the same durable capture and native lineage pipeline as the CLI.""" from compilerlens.services import capture job = JOBS[job_id] started = time.monotonic() diff --git a/compilerlens/cli.py b/compilerlens/cli.py index e0e79c3..addd64c 100644 --- a/compilerlens/cli.py +++ b/compilerlens/cli.py @@ -82,7 +82,7 @@ def parser(): def doctor_report(): - from .provenance import native_binary + from .lineage import native_binary from .services import versions from .toolchain import find_tool native = native_binary() @@ -115,7 +115,7 @@ def render(value, *, json_output=False, kind=None): return if kind == 'summary': console.print(f"CompilerLens · {value['compilation_id']}", style='bold', markup=False) - console.print(f"{value['stage_count']} stages · {value['evidence_count']} evidence items · provenance: {value['provenance']}") + console.print(f"{value['stage_count']} stages · {value['evidence_count']} evidence items · lineage: {value['lineage']}") console.print('Target: ' + json.dumps(value['target']), markup=False) if value['coverage']: render([{'Stage': name, 'Ops': c['instructions'], 'Debug': c.get('debug_locations', '—'), @@ -176,27 +176,27 @@ def main(argv=None): value = benchmark_run(args.run, workers=args.workers, repetitions=args.repetitions) else: from .queries import inspect_report, trace_report, diff_report - root, artifact, provenance = load_run(args.run) + root, artifact, lineage = load_run(args.run) if command == 'inspect': if args.stage and args.show not in ('evidence', 'ops', 'ir'): raise ValueError('--stage applies to --show evidence, ops or ir.') if args.lines and args.show not in ('ops', 'ir'): raise ValueError('--lines applies to --show ops or ir.') if args.module and args.show not in ('ops', 'architecture'): raise ValueError('--module applies to --show ops or architecture.') if args.show == 'objects': - from .provenance import object_sections + from .lineage import object_sections value = object_sections(root) else: - value = inspect_report(artifact, provenance, show=args.show, stage=args.stage, module=args.module, lines=args.lines) + value = inspect_report(artifact, lineage, show=args.show, stage=args.stage, module=args.module, lines=args.lines) kind = args.show elif command == 'trace': if args.object: if not args.section or args.address is None or args.line is not None: raise ValueError('--object requires --section and --address (not --line).') - from .provenance import object_lookup + from .lineage import object_lookup value = object_lookup(root, args.object, args.section, args.address) else: if args.section or args.address is not None: raise ValueError('--section and --address require --object.') - value = trace_report(artifact, provenance, op=args.op, module=args.module, source=args.source, from_stage=args.from_stage, line=args.line, to=args.to) + value = trace_report(artifact, lineage, op=args.op, module=args.module, source=args.source, from_stage=args.from_stage, line=args.line, to=args.to) kind = 'trace' - else: value = diff_report(artifact, provenance, args.before, args.after, args.mode) + else: value = diff_report(artifact, lineage, args.before, args.after, args.mode) render(value, json_output=fmt == 'json', kind=kind) return 0 if command != 'doctor' or value['ready'] else 1 except (OSError, ValueError, RuntimeError, ImportError, subprocess.SubprocessError) as exc: diff --git a/compilerlens/ingest/build.py b/compilerlens/ingest/build.py index 08fd050..f6f95ab 100644 --- a/compilerlens/ingest/build.py +++ b/compilerlens/ingest/build.py @@ -474,10 +474,10 @@ def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, analysis source_sets = None if analysis is not None: - from compilerlens.provenance import enrich_stages + from compilerlens.lineage import enrich_stages from compilerlens.storage import atomic_json sidecar, source_sets = enrich_stages(dump_root, stages, analysis) - atomic_json(dump_root / "provenance.json", sidecar) + atomic_json(dump_root / "lineage.json", sidecar) # Evidence, diffs and lineage all read the full op index, so trimming happens only after # they have been derived. Lineage in particular *must* precede it: trimming clears the op @@ -492,7 +492,7 @@ def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, analysis for line in node.get('source_lines', [])} for entry in sidecar['stages'].values(): entry['coverage']['module_associated'] = sum(any(o['line'] in mapped_lines for o in r['origins']) for r in entry['records']) - atomic_json(dump_root / 'provenance.json', sidecar) + atomic_json(dump_root / 'lineage.json', sidecar) located = sum(1 for s in stages for op in s.ops if op.source_loc) trim_note = _trim_op_index(stages) @@ -504,7 +504,7 @@ def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, analysis ] notes.extend(stage_notes) if analysis is not None: - notes.append("Provenance: " + analysis["status"] + ". Native IDs are snapshot-local; assembly links use emitted .file/.loc associations. Source attribution is not instruction identity.") + notes.append("Lineage: " + analysis["status"] + ". Native IDs are snapshot-local; assembly links use emitted .file/.loc associations. Source attribution is not instruction identity.") notes.extend(analysis["notes"]) if trim_note: notes.append(trim_note) diff --git a/compilerlens/ingest/schema.py b/compilerlens/ingest/schema.py index 80ec941..ab8b6e6 100644 --- a/compilerlens/ingest/schema.py +++ b/compilerlens/ingest/schema.py @@ -92,7 +92,7 @@ class Stage: title: str # human label, "Executable Targets" phase: str # one of the PHASE_* constants language: str # "mlir" | "llvm" | "asm" | "python" -- drives syntax highlighting - source_path: str # path relative to repo root, for provenance + source_path: str # path relative to repo root, for lineage text: str # the full IR, embedded so the frontend needs one fetch line_count: int = 0 byte_size: int = 0 diff --git a/compilerlens/provenance.py b/compilerlens/lineage.py similarity index 99% rename from compilerlens/provenance.py rename to compilerlens/lineage.py index 851621d..2cee570 100644 --- a/compilerlens/provenance.py +++ b/compilerlens/lineage.py @@ -27,7 +27,7 @@ def native_binary() -> Path | None: path = Path(override).expanduser().resolve() return path if path.is_file() else None package = Path(__file__).resolve().parent - for path in (package / '_bin' / 'compilerlens-native', package.parent / 'build/native/compilerlens-native'): + for path in (package / '_bin' / 'compilerlens-native', package.parent / 'build/llvm/compilerlens-native'): if path.is_file() and os.access(path, os.X_OK): return path found = shutil.which('compilerlens-native') @@ -145,7 +145,7 @@ def assembly_locations(text: str) -> dict[int, dict]: def analyze_files(root: Path, mode: str = 'auto', progress=None) -> dict: reports = {} if mode == 'off': - return {'status': 'off', 'reports': reports, 'notes': ['Additional provenance was disabled.']} + return {'status': 'off', 'reports': reports, 'notes': ['Additional lineage was disabled.']} executable = native_binary() if executable is None: if mode == 'required': @@ -155,7 +155,7 @@ def analyze_files(root: Path, mode: str = 'auto', progress=None) -> dict: native_dir = root / 'native' native_dir.mkdir(exist_ok=True) for path in sorted((root / 'llvm').glob('*.ll')): - if progress: progress(f'Native provenance: {path.name}') + if progress: progress(f'Native lineage: {path.name}') report_path = native_dir / (path.name + '.json') view_path = native_dir / (path.name + '.annotated.ll') command = [str(executable), '--input', str(path), '--output', str(report_path), '--annotated-ir', str(view_path)] diff --git a/compilerlens/models/architecture.py b/compilerlens/models/architecture.py index 21c30aa..474cdff 100644 --- a/compilerlens/models/architecture.py +++ b/compilerlens/models/architecture.py @@ -1,6 +1,6 @@ """Capture a model's module hierarchy and connect it to exported Torch operations. -The important property is provenance, not visual similarity. We use the nn_module_stack +The important property is lineage, not visual similarity. We use the nn_module_stack metadata emitted by torch.export and only attach Torch-MLIR line numbers when the complete ordered operation stream agrees with the exact ExportedProgram passed to Turbine. """ diff --git a/compilerlens/queries.py b/compilerlens/queries.py index f15db64..cbff9db 100644 --- a/compilerlens/queries.py +++ b/compilerlens/queries.py @@ -17,8 +17,8 @@ def module_for(artifact, path): return matches[0] -def operations(stage, provenance): - saved = provenance.get('stages', {}).get(stage['id'], {}) +def operations(stage, lineage): + saved = lineage.get('stages', {}).get(stage['id'], {}) if saved.get('records'): lines = stage['text'].splitlines() return [{**r, 'id': r['id'] if r['id'].startswith(stage['id'] + ':') else f"{stage['id']}:{r['id']}", @@ -35,7 +35,7 @@ def operations(stage, provenance): return [{**op, 'id': f"{stage['id']}:op{op['line']}"} for op in parsed] -def inspect_report(artifact, provenance, *, show='summary', stage=None, module=None, lines=None): +def inspect_report(artifact, lineage, *, show='summary', stage=None, module=None, lines=None): selected = stage_for(artifact, stage) if stage else None node = module_for(artifact, module) if module else None source_lines = set(node.get('source_lines', [])) if node else None @@ -43,8 +43,8 @@ def inspect_report(artifact, provenance, *, show='summary', stage=None, module=N return {'compilation_id': artifact['compilation_id'], 'target': artifact['target'], 'stage_count': len(artifact['stages']), 'evidence_count': len(artifact['evidence']), 'architecture_mapping': artifact.get('architecture', {}).get('mapping_status'), - 'provenance': provenance.get('status', 'legacy'), 'notes': artifact['notes'], - 'coverage': {s['name']: s['coverage'] for s in provenance.get('stages', {}).values() if s['language'] in ('llvm', 'asm')}} + 'lineage': lineage.get('status', 'legacy'), 'notes': artifact['notes'], + 'coverage': {s['name']: s['coverage'] for s in lineage.get('stages', {}).values() if s['language'] in ('llvm', 'asm')}} if show == 'architecture': architecture = artifact.get('architecture', {}) if node: return {**architecture, 'nodes': [n for n in architecture.get('nodes', []) if n['id'] == node['id'] or n.get('path', '').startswith(node.get('path', '') + '.')]} @@ -62,7 +62,7 @@ def inspect_report(artifact, provenance, *, show='summary', stage=None, module=N if start < 1 or end < start or end > len(text): raise ValueError(f'Line range must be within 1:{len(text)}.') return {'stage': selected['name'], 'language': selected['language'], 'start_line': start, 'end_line': end, 'text': '\n'.join(text[start-1:end])} - ops = operations(selected, provenance) + ops = operations(selected, lineage) if source_lines is not None: if selected['name'] == 'torch-input': ops = [op for op in ops if op['line'] in source_lines] else: ops = [op for op in ops if any(o['line'] in source_lines for o in op.get('origins', []))] @@ -70,17 +70,17 @@ def inspect_report(artifact, provenance, *, show='summary', stage=None, module=N return ops -def trace_report(artifact, provenance, *, op=None, module=None, source=None, from_stage=None, line=None, to='all'): +def trace_report(artifact, lineage, *, op=None, module=None, source=None, from_stage=None, line=None, to='all'): if sum(v is not None for v in (op, module, source, from_stage)) != 1: raise ValueError('Choose one --op, --module, --source or --from-stage selector.') if (from_stage is None) != (line is None): raise ValueError('--from-stage and --line must be supplied together.') - if not provenance: raise ValueError('This legacy artifact has no provenance sidecar; import its dump directory first.') + if not lineage: raise ValueError('This legacy artifact has no lineage sidecar; import its dump directory first.') result = {'relationship': 'compiler-recorded source attribution; not persistent instruction identity', - 'provenance': provenance['status'], 'origins': [], 'matches': [], 'notes': list(provenance.get('notes', []))} + 'lineage': lineage['status'], 'origins': [], 'matches': [], 'notes': list(lineage.get('notes', []))} if from_stage: stage = stage_for(artifact, from_stage) if line < 1 or line > stage['line_count']: raise ValueError('Line is outside this stage.') - found = [r for r in operations(stage, provenance) if r['line'] == line] + found = [r for r in operations(stage, lineage) if r['line'] == line] result['matches'] = [{'stage': stage['name'], 'stage_id': stage['id'], **r} for r in found] result['origins'] = list({(o['file'], o['line'], o['column']): o for r in found for o in r.get('origins', [])}.values()) if not result['origins']: result['notes'].append('No resolved source attribution at this display line.') @@ -99,7 +99,7 @@ def trace_report(artifact, provenance, *, op=None, module=None, source=None, fro result['module_mapping'] = node.get('mapping', 'unavailable') else: stage = stage_for(artifact, op.split(':', 1)[0]) - found = [r for r in operations(stage, provenance) if r['id'] == op] + found = [r for r in operations(stage, lineage) if r['id'] == op] if not found: raise ValueError(f'Unknown operation {op!r}; inspect --show ops lists IDs.') if stage['id'] == anchor['id']: selected_lines = {found[0]['line']} @@ -110,20 +110,20 @@ def trace_report(artifact, provenance, *, op=None, module=None, source=None, fro exact = {(o['file'], o['line'], o['column']) for o in result['origins']} for stage in artifact['stages']: if stage['language'] not in (('llvm', 'asm') if to == 'all' else (to,)): continue - for r in operations(stage, provenance): + for r in operations(stage, lineage): origins = [o for o in r.get('origins', []) if o['line'] in selected_lines and (columns is None or o['column'] in columns)] if op and 'exact' in locals(): origins = [o for o in origins if (o['file'], o['line'], o['column']) in exact] if origins: result['matches'].append({'stage': stage['name'], 'stage_id': stage['id'], **r}) result['origins'] = list({(o['file'], o['line'], o['column']): o for r in result['matches'] for o in r.get('origins', []) if o['line'] in selected_lines and (columns is None or o['column'] in columns)}.values()) - if not result['matches']: result['notes'].append('No captured destination instructions resolve to this selector. Missing provenance is not inferred from operands.') + if not result['matches']: result['notes'].append('No captured destination instructions resolve to this selector. Missing lineage is not inferred from operands.') return result -def diff_report(artifact, provenance, before, after, mode='text'): +def diff_report(artifact, lineage, before, after, mode='text'): a, b = stage_for(artifact, before), stage_for(artifact, after) if a['track'] != b['track']: raise ValueError(f"Incompatible comparison tracks: {a['track']} and {b['track']}.") if mode == 'text': return ''.join(difflib.unified_diff(a['text'].splitlines(True), b['text'].splitlines(True), fromfile=a['name'], tofile=b['name'])) - ca, cb = (Counter(r.get('opcode', r.get('name')) for r in operations(s, provenance)) for s in (a, b)) + ca, cb = (Counter(r.get('opcode', r.get('name')) for r in operations(s, lineage)) for s in (a, b)) return {'from': a['name'], 'to': b['name'], 'track': a['track'], 'op_delta': b['op_count'] - a['op_count'], 'operations': {k: cb[k]-ca[k] for k in sorted(ca.keys() | cb.keys()) if ca[k] != cb[k]}, 'note': 'Operation-count comparison; not a proof of equivalence or a transformation attribution.'} diff --git a/compilerlens/services.py b/compilerlens/services.py index a2c3d29..d29c8dc 100644 --- a/compilerlens/services.py +++ b/compilerlens/services.py @@ -47,7 +47,7 @@ def versions(): def _finish(root, info, lineage, progress): - from .provenance import analyze_files + from .lineage import analyze_files from .ingest.build import build_artifact, build_index from .ingest.workloads.generated import spec_from_dump_dir analysis = analyze_files(root, lineage, progress) @@ -76,7 +76,7 @@ def capture(model_id=None, *, example=None, python=None, out=None, seq_len=None, if capture not in ('standard', 'full') or lineage not in ('auto', 'required', 'off'): raise ValueError('Invalid capture or lineage mode.') if lineage == 'required': - from .provenance import native_binary + from .lineage import native_binary if not native_binary(): raise RuntimeError('Required native analyzer is unavailable; run compilerlens doctor.') root = _fresh(out, model_id or example or Path(python.split(':')[0]).stem) progress = progress or (lambda label: None) @@ -143,7 +143,7 @@ def report(label): if manifest.get('errors'): failure = manifest['errors'][0] raise RuntimeError(f"Compiler failed at {failure['stage']}: {failure.get('stderr', '')[-2000:]}") - state['provenance'] = _finish(root, info, lineage, report) + state['lineage'] = _finish(root, info, lineage, report) state['status'] = 'complete' # Retain _full: it is the authoritative export for recompile/reproducibility. state['file_hashes'] = {str(p.relative_to(root)): digest(p) for folder in ('mlir', 'llvm', '_full') @@ -176,7 +176,7 @@ def import_dump(source, *, out=None, lineage='auto', progress=None) -> Path: info_path = root / 'model_info.json' info = json.loads(info_path.read_text()) if info_path.exists() else {'model_id': source.name, 'kind': 'import'} atomic_json(info_path, info) - state['provenance'] = _finish(root, info, lineage, progress or (lambda label: None)) + state['lineage'] = _finish(root, info, lineage, progress or (lambda label: None)) state['status'] = 'complete' except BaseException as exc: state.update(status='failed', error=f'{type(exc).__name__}: {exc}') diff --git a/compilerlens/storage.py b/compilerlens/storage.py index dfcb443..7c3b547 100644 --- a/compilerlens/storage.py +++ b/compilerlens/storage.py @@ -49,8 +49,11 @@ def load_run(path: str | Path) -> tuple[Path, dict, dict]: if artifact.get('artifact_version') != '0.7' or not artifact.get('stages'): raise ValueError('Expected a CompilerLens artifact version 0.7 with stages.') root = artifact_path.parent - provenance_path = root / 'provenance.json' - provenance = json.loads(provenance_path.read_text()) if provenance_path.exists() else {} - if provenance and provenance.get('schema_version') != 1: - raise ValueError('Unsupported provenance schema; use a compatible CompilerLens version.') - return root, artifact, provenance + lineage_path = root / 'lineage.json' + if not lineage_path.exists(): + # Read saved runs produced by the TestPyPI 0.1.0 release. + lineage_path = root / 'provenance.json' + lineage = json.loads(lineage_path.read_text()) if lineage_path.exists() else {} + if lineage and lineage.get('schema_version') != 1: + raise ValueError('Unsupported lineage schema; use a compatible CompilerLens version.') + return root, artifact, lineage diff --git a/docs/CLI.md b/docs/CLI.md index bf67890..326d3c0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,4 +1,7 @@ -# CompilerLens CLI and native provenance +# CompilerLens CLI and native lineage + +[Project overview](../README.md) · [LLVM component](../llvm/README.md) · +[Release checklist](RELEASING.md) The `feature/experiments-cli` branch provides a complete Python package, the existing viewer, a standalone LLVM analyzer and a loadable LLVM New Pass Manager plugin. The packaged analyzer @@ -6,6 +9,10 @@ runs on actual IREE output. Source attribution and LLVM def-use edges are separa ## Install the built wheel +For a fresh virtual environment and installation from TestPyPI, follow the +[CLI quick start](../README.md#cli-quick-start). The commands below assume an existing +local wheel build. + ```bash python -m pip install dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl \ --extra-index-url https://download.pytorch.org/whl/cpu @@ -15,13 +22,19 @@ compilerlens inspect runs/matmul compilerlens view runs/matmul ``` -The wheel is local; it has **not** been uploaded to PyPI. It bundles the existing React app, +Version 0.1.0 was tested through TestPyPI; production PyPI publication and installation +testing are pending. The local build command above uses the original wheel in `dist/`. +The tested, repaired wheel is +`wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`; the +[release checklist](RELEASING.md) explains how to download it from TestPyPI. +It bundles the existing React app, Monaco workers, private native executable, LLVM license, zlib and zstd libraries/licenses. End users do not need Node, CMake, `opt` or an LLVM SDK. Dependencies include the validated PyTorch, Transformers, IREE compiler/runtime and Turbine versions in `pyproject.toml`. The first wheel targets Linux x86-64 and was built/tested on glibc 2.35 with Python 3.10. -It has a `linux_x86_64` tag, not an audited manylinux tag. macOS, Windows, ARM and older glibc +The build first produces a `linux_x86_64` wheel; auditwheel repair produces the tested +`manylinux_2_35_x86_64` wheel. macOS, Windows, ARM and older glibc are not release targets. An LLVM 22 analyzer successfully parses the tested IREE 3.11.0 LLVM 23 development snapshots; arbitrary future IR compatibility is not promised. @@ -66,7 +79,7 @@ mlir/, llvm/, passes/ captured stages, every dispatch, LLVM checkpoints, ass _full/ authoritative exports/resources retained for reproducibility native/ per-snapshot JSON and annotated LLVM display views artifact.json unchanged viewer schema 0.7 -provenance.json schema 1; origin sets, inline frames, def-use and coverage +lineage.json schema 1; origin sets, inline frames, def-use and coverage index.json viewer workload entry ``` @@ -75,6 +88,10 @@ Full capture can produce very large pass logs. Runs are self-contained and can b Legacy imports without manifests use unique captured-file aliases and report gaps where metadata is absent; they cannot recover information the compiler never emitted. +Current source uses `lineage` for the analysis module, pass, CLI output fields and sidecar. +The reader also accepts the older `provenance.json` sidecar from TestPyPI 0.1.0 runs. +The uploaded wheel retains its original names; these source changes need a new release. + ## Inspect, trace, compare and benchmark ```bash @@ -99,7 +116,7 @@ Use `--format json` for complete structured records. Text output uses Rich table IR highlighting; piped output works without terminal color or an interactive pager. Forward queries keep every explicit origin attached to matching records. Reverse queries -show unknown provenance explicitly. `.file`/`.loc` state resets on line zero, section changes +show unknown lineage explicitly. `.file`/`.loc` state resets on line zero, section changes and function boundaries. Inline callsites are distinguished from primary debug locations. A debug location is a source association; it does not prove one-to-one instruction ancestry. @@ -128,14 +145,18 @@ returns recorded frames and joins them to the captured dispatch and Torch source ## Build/develop the native pass +See the [LLVM component overview](../llvm/README.md) for the analysis flow and file layout. +Configure a fresh `build/llvm/` directory after the source-directory rename; CMake caches +contain absolute source paths and should not be moved from the previous build directory. + ```bash -cmake -S native -B build/native -G Ninja \ +cmake -S llvm -B build/llvm -G Ninja \ -DLLVM_DIR=/path/to/llvm/lib/cmake/llvm -DCMAKE_BUILD_TYPE=Release -cmake --build build/native --parallel 2 -ctest --test-dir build/native --output-on-failure -/path/to/llvm/bin/opt -load-pass-plugin=build/native/CompilerLensPasses.so \ - -passes=compilerlens-provenance -disable-output run/llvm/FILE.optimized.ll -build/native/compilerlens-native --input run/llvm/FILE.optimized.ll \ +cmake --build build/llvm --parallel 2 +ctest --test-dir build/llvm --output-on-failure +/path/to/llvm/bin/opt -load-pass-plugin=build/llvm/CompilerLensPasses.so \ + -passes=compilerlens-lineage -disable-output run/llvm/FILE.optimized.ll +build/llvm/compilerlens-native --input run/llvm/FILE.optimized.ll \ --output report.json --annotated-ir view.ll ``` @@ -149,7 +170,7 @@ for source-build development, which requires validation with that particular SDK ABI must match its host. `COMPILERLENS_NATIVE=/path/to/compilerlens-native` overrides the private executable; `COMPILERLENS_WORKSPACE=/path` selects server storage. -An optional [IREE source-build hook](../native/iree/README.md) brackets its actual optimization +An optional [IREE source-build hook](../llvm/iree/README.md) brackets its actual optimization pipeline, including O0. Its C++ hook is tested; the pinned source patch is supplied. A full IREE source build has not been performed. The ordinary wheel uses emitted snapshots. There is no assumed `iree-compile --load-pass-plugin` support. @@ -175,7 +196,7 @@ Playwright Chromium installation. Browser components and styling are unchanged. On this workspace's LLVM SDK, configure using: ```bash -/pkg/qct/software/cmake/3.31.5/bin/cmake -S native -B build/native -G Ninja \ +/pkg/qct/software/cmake/3.31.5/bin/cmake -S llvm -B build/llvm -G Ninja \ -DLLVM_DIR=/pkg/qct/software/llvm/22.1.8/lib/cmake/llvm \ -DCMAKE_C_COMPILER=/pkg/qct/software/llvm/22.1.8/bin/clang \ -DCMAKE_CXX_COMPILER=/pkg/qct/software/llvm/22.1.8/bin/clang++ \ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 13a33ba..0425372 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,7 +1,15 @@ # CompilerLens: TestPyPI to GitHub and PyPI -Run these steps in order. They describe actions for you to perform; creating this checklist -has not committed, pushed, merged, tagged or published anything. +[Project overview](../README.md) · [CLI guide](CLI.md) · [LLVM component](../llvm/README.md) + +Current status (28 September 2026): version 0.1.0 has been tested through TestPyPI. +Production PyPI publication and installation testing remain pending. The initial +implementation is committed as `42c45f4` on `feature/experiments-cli`. + +The `llvm/` directory and lineage terminology changes are subsequent source changes. +They are not in the existing TestPyPI wheel. Build and validate a new version to release +these changes; do not overwrite or relabel the existing 0.1.0 artifact. The 0.1.0 commands +below describe the already tested artifact and its publication procedure. Verified on 25 September 2026: @@ -9,7 +17,7 @@ Verified on 25 September 2026: - Its uploaded wheel's SHA-256 matches the local repaired wheel in `wheelhouse/`. - The wheel is `compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`. - GitHub remote: `git@github.com:Soumyaworks/CompilerLens.git`; default branch: `main`. -- Current branch: `feature/experiments-cli`; implementation changes are not yet committed. +- Implementation branch: `feature/experiments-cli` (committed after these checks). - No production PyPI project is publicly visible under `compilerlens` at the time of checking. TestPyPI does not reserve that name on production PyPI. @@ -17,9 +25,9 @@ Local prepublication checks for this uploaded wheel are now complete: fresh Test fresh dependency installation from the normal indexes, `pip check`, required native matmul capture (33 stages), assembly tracing (208 matches), benchmarking, and the installed browser flow all ran successfully. All 21 Python tests, native CTest and wheel metadata checks passed. -The benchmark flagged its timings as noisy; it is not a performance result. The release source -files are staged, with generated artifacts and unrelated experiments excluded. No commit, push -or production publication was performed during these checks. +The benchmark flagged its timings as noisy; it is not a performance result. Generated +artifacts and unrelated experiments were excluded from the subsequent implementation commit. +No production publication was performed during these checks. Keep the uploaded `0.1.0` wheel unchanged while following these steps. If code, dependencies or packaged metadata need fixing, use a new version and repeat the TestPyPI checks. Git-only @@ -117,20 +125,21 @@ cd "$CLENS_REPO" ``` That browser directory was downloaded during implementation. If it has been removed, install -Playwright's Chromium again as described in `docs/CLI.md`. +Playwright's Chromium again as described in [the CLI guide](CLI.md). ## 4. Run the source checks and validate the release file ```bash cd "$CLENS_REPO" .venv/bin/python -m unittest discover -s tests -v -/pkg/qct/software/cmake/3.31.5/bin/ctest --test-dir build/native --output-on-failure +/pkg/qct/software/cmake/3.31.5/bin/ctest --test-dir build/llvm --output-on-failure .venv/bin/python -m twine check \ wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl ``` -The current suite has 21 Python tests and the native CTest integration check. The CTest command -assumes the existing configured native build on this workstation; use `docs/CLI.md` to build +The current suite has 22 Python tests, including old-run compatibility, and the native CTest +integration check. The original 0.1.0 validation ran 21 Python tests. The CTest command +assumes the existing configured native build on this workstation; use [the CLI guide](CLI.md) to build it on another machine. The release script already runs TypeScript checking when building assets. Keep the repaired wheel in `wheelhouse/`. That is the file to publish, not the original generic @@ -138,36 +147,38 @@ Keep the repaired wheel in `wheelhouse/`. That is the file to publish, not the o ## 5. Stage exactly the release's source files -The following files/directories belong in this feature commit: +The initial feature is committed. For follow-up releases, review changes in these paths: | Path | Purpose | |---|---| | `compilerlens/` | Canonical Python package, CLI, capture/query services, examples and migrated implementation | | `backend/`, `ingest/`, `models/` | Compatibility imports and removal of the old implementation paths | -| `native/` | C++ pass/analyzer, CMake, LLVM license, fixtures and optional IREE hook | +| `llvm/` | C++ pass/analyzer, CMake, LLVM license, fixtures and optional IREE hook | | `pyproject.toml`, `setup.py`, `MANIFEST.in` | Package metadata and wheel/source packaging | | `frontend/vite.release.config.ts` | Bundle the existing frontend for installation | | `scripts/build_release.py`, `scripts/check_installed.py`, `scripts/check_viewer.mjs` | Release build and installation/browser validation | | `scripts/compile_hf_model.py` | Updated imports for the package move | -| `tests/test_architecture.py`, `tests/test_llvm_lineage.py`, `tests/test_cli_provenance.py` | Existing adjusted tests and new regression tests | -| `.gitignore`, `README.md`, `FINALS_5_DAY_PLAN.md`, `docs/CLI.md`, `docs/RELEASING.md` | Ignore rules and documentation | +| `tests/test_architecture.py`, `tests/test_llvm_lineage.py`, `tests/test_cli_lineage.py` | Existing adjusted tests and new regression tests | +| `.gitignore`, `README.md`, [the CLI guide](CLI.md), `docs/RELEASING.md` | Ignore rules and documentation | -Run these scoped staging commands: +For a follow-up commit, stage tracked changes (including the old directory deletions) +and the scoped new source files: ```bash cd "$CLENS_REPO" -git add -A -- compilerlens/ backend/ ingest/ models/ native/ \ +git add -u -- . ':(exclude)**/__pycache__/**' +git add -A -- compilerlens/ backend/ ingest/ models/ llvm/ \ ':(exclude)**/__pycache__/**' git add -- \ - .gitignore README.md FINALS_5_DAY_PLAN.md \ + .gitignore README.md \ pyproject.toml setup.py MANIFEST.in \ docs/CLI.md docs/RELEASING.md \ frontend/vite.release.config.ts \ scripts/build_release.py scripts/check_installed.py scripts/check_viewer.mjs \ scripts/compile_hf_model.py \ - tests/test_architecture.py tests/test_llvm_lineage.py tests/test_cli_provenance.py + tests/test_architecture.py tests/test_llvm_lineage.py tests/test_cli_lineage.py git diff --cached --check git diff --cached --stat @@ -197,7 +208,7 @@ Remaining `??` entries for unrelated experiments are expected after staging. After reviewing the staged diff: ```bash -git commit -m "Add installable CLI and native LLVM provenance" +git commit -m "Add installable CLI and native LLVM lineage" git push -u origin feature/experiments-cli ``` @@ -209,7 +220,7 @@ Open a pull request into `main`: https://github.com/Soumyaworks/CompilerLens/compare/main...feature/experiments-cli -Suggested title: `Add installable CLI and native LLVM provenance`. +Suggested title: `Add installable CLI and native LLVM lineage`. Describe the packaged CLI, native reporting pass, source/assembly/DWARF tracing, unchanged viewer and validation results. State that the release wheel targets Linux x86-64/glibc 2.35+, @@ -293,7 +304,7 @@ On https://github.com/Soumyaworks/CompilerLens/releases/new: 1. Select tag `v0.1.0` and title `CompilerLens 0.1.0`. 2. Describe the CLI/native pass, supported platform and install command. -3. Link to https://pypi.org/project/compilerlens/0.1.0/ and `docs/CLI.md`. +3. Link to https://pypi.org/project/compilerlens/0.1.0/ and [the CLI guide](CLI.md). 4. Attach `wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`. 5. Publish the release. @@ -314,13 +325,13 @@ For a `0.1.1` release, update the version strings in these six places: ```text pyproject.toml project.version compilerlens/__init__.py __version__ -native/CMakeLists.txt project version -native/tools/main.cpp --version output -native/lib/Plugin.cpp plugin version -native/lib/Provenance.cpp pass_version +llvm/CMakeLists.txt project version +llvm/tools/main.cpp --version output +llvm/lib/Plugin.cpp plugin version +llvm/lib/Lineage.cpp pass_version ``` -Do not bump artifact/provenance schema versions merely because the package version changed. +Do not bump artifact/lineage schema versions merely because the package version changed. Update documentation examples as needed; historical release notes should retain their versions. The existing uploaded wheel has no project URL metadata. Add this for the next version in diff --git a/frontend/src/ArchitectureExplorerPage.tsx b/frontend/src/ArchitectureExplorerPage.tsx index 2a3fe47..6d144bf 100644 --- a/frontend/src/ArchitectureExplorerPage.tsx +++ b/frontend/src/ArchitectureExplorerPage.tsx @@ -268,7 +268,7 @@ function Explorer({ /> -
+

{architecture.mapping_note}

diff --git a/frontend/src/styles/architecture.css b/frontend/src/styles/architecture.css index 0fad33a..e165819 100644 --- a/frontend/src/styles/architecture.css +++ b/frontend/src/styles/architecture.css @@ -256,9 +256,9 @@ .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; diff --git a/native/CMakeLists.txt b/llvm/CMakeLists.txt similarity index 79% rename from native/CMakeLists.txt rename to llvm/CMakeLists.txt index f9f17ce..822ebef 100644 --- a/native/CMakeLists.txt +++ b/llvm/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(CompilerLensNative VERSION 0.1.0 LANGUAGES C CXX) +project(CompilerLensLLVM VERSION 0.1.0 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) @@ -17,14 +17,14 @@ 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(CompilerLensProvenance STATIC lib/Provenance.cpp) -llvm_update_compile_flags(CompilerLensProvenance) +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 CompilerLensProvenance) +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 CompilerLensProvenance ${CLENS_LIBS}) +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) @@ -33,8 +33,8 @@ 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 CompilerLensProvenance ${CLENS_LIBS}) - add_test(NAME provenance COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_reports.py + 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/native/LLVM-LICENSE.txt b/llvm/LLVM-LICENSE.txt similarity index 100% rename from native/LLVM-LICENSE.txt rename to llvm/LLVM-LICENSE.txt diff --git a/llvm/README.md b/llvm/README.md new file mode 100644 index 0000000..97fe608 --- /dev/null +++ b/llvm/README.md @@ -0,0 +1,53 @@ +# 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 + +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. + +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/native/include/compilerlens/Checkpoint.h b/llvm/include/compilerlens/Checkpoint.h similarity index 93% rename from native/include/compilerlens/Checkpoint.h rename to llvm/include/compilerlens/Checkpoint.h index ac529f2..086c79d 100644 --- a/native/include/compilerlens/Checkpoint.h +++ b/llvm/include/compilerlens/Checkpoint.h @@ -1,6 +1,6 @@ #pragma once // Optional source-build hook. Uses the host IREE build's LLVM, never a foreign plugin. -#include "compilerlens/Provenance.h" +#include "compilerlens/Lineage.h" #include "llvm/ADT/SmallString.h" #include "llvm/IR/Module.h" #include "llvm/Support/FileSystem.h" @@ -15,7 +15,7 @@ inline void checkpoint(llvm::Module &M, llvm::ModuleAnalysisManager &AM, if (!Directory || !*Directory) return; llvm::json::Object Report; llvm::ModulePassManager PM; - PM.addPass(ProvenancePass(&Report)); + PM.addPass(LineagePass(&Report)); PM.run(M, AM); Report["checkpoint"] = Phase.str(); if (auto EC = llvm::sys::fs::create_directories(Directory)) { diff --git a/native/include/compilerlens/Provenance.h b/llvm/include/compilerlens/Lineage.h similarity index 72% rename from native/include/compilerlens/Provenance.h rename to llvm/include/compilerlens/Lineage.h index 2f7aca3..bff2cef 100644 --- a/native/include/compilerlens/Provenance.h +++ b/llvm/include/compilerlens/Lineage.h @@ -5,10 +5,10 @@ namespace compilerlens { llvm::json::Object analyze(llvm::Module &M); void printAnnotated(llvm::Module &M, llvm::raw_ostream &OS); -class ProvenancePass : public llvm::PassInfoMixin { +class LineagePass : public llvm::PassInfoMixin { llvm::json::Object *Result; public: - explicit ProvenancePass(llvm::json::Object *Result = nullptr) : Result(Result) {} + 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/native/iree/.gitattributes b/llvm/iree/.gitattributes similarity index 100% rename from native/iree/.gitattributes rename to llvm/iree/.gitattributes diff --git a/native/iree/CompilerLens.cmake b/llvm/iree/CompilerLens.cmake similarity index 74% rename from native/iree/CompilerLens.cmake rename to llvm/iree/CompilerLens.cmake index 9eef99a..86ab2b5 100644 --- a/native/iree/CompilerLens.cmake +++ b/llvm/iree/CompilerLens.cmake @@ -3,8 +3,8 @@ 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_native "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) +get_filename_component(_compilerlens_llvm "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) target_sources(iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses PRIVATE - "${_compilerlens_native}/lib/Provenance.cpp") + "${_compilerlens_llvm}/lib/Lineage.cpp") target_include_directories(iree_compiler_plugins_target_LLVMCPU_LLVMIRPasses PRIVATE - "${_compilerlens_native}/include") + "${_compilerlens_llvm}/include") diff --git a/native/iree/LLVMIRPasses.patch b/llvm/iree/LLVMIRPasses.patch similarity index 100% rename from native/iree/LLVMIRPasses.patch rename to llvm/iree/LLVMIRPasses.patch diff --git a/native/iree/README.md b/llvm/iree/README.md similarity index 90% rename from native/iree/README.md rename to llvm/iree/README.md index f1435cb..6a9e06b 100644 --- a/native/iree/README.md +++ b/llvm/iree/README.md @@ -1,5 +1,8 @@ # 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. diff --git a/native/lib/Provenance.cpp b/llvm/lib/Lineage.cpp similarity index 97% rename from native/lib/Provenance.cpp rename to llvm/lib/Lineage.cpp index fc396d6..6e1bcd0 100644 --- a/native/lib/Provenance.cpp +++ b/llvm/lib/Lineage.cpp @@ -1,4 +1,4 @@ -#include "compilerlens/Provenance.h" +#include "compilerlens/Lineage.h" #include "llvm/ADT/DenseMap.h" #include "llvm/ADT/SmallPtrSet.h" #include "llvm/Config/llvm-config.h" @@ -98,7 +98,7 @@ void printAnnotated(Module &M, raw_ostream &OS) { AnnotationWriter Writer(Index); M.print(OS, &Writer); } -PreservedAnalyses ProvenancePass::run(Module &M, ModuleAnalysisManager &) { +PreservedAnalyses LineagePass::run(Module &M, ModuleAnalysisManager &) { auto Report = analyze(M); if (Result) *Result = std::move(Report); else outs() << json::Value(std::move(Report)) << '\n'; diff --git a/native/lib/Plugin.cpp b/llvm/lib/Plugin.cpp similarity index 76% rename from native/lib/Plugin.cpp rename to llvm/lib/Plugin.cpp index 86de7d1..9ed144e 100644 --- a/native/lib/Plugin.cpp +++ b/llvm/lib/Plugin.cpp @@ -1,12 +1,12 @@ -#include "compilerlens/Provenance.h" +#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.1.0", [](llvm::PassBuilder &PB) { PB.registerPipelineParsingCallback([](llvm::StringRef Name, llvm::ModulePassManager &MPM, llvm::ArrayRef) { - if (Name != "compilerlens-provenance") return false; - MPM.addPass(compilerlens::ProvenancePass()); + if (Name != "compilerlens-lineage") return false; + MPM.addPass(compilerlens::LineagePass()); return true; }); }}; diff --git a/native/tests/check_reports.py b/llvm/tests/check_reports.py similarity index 97% rename from native/tests/check_reports.py rename to llvm/tests/check_reports.py index 8a523f9..61154dd 100644 --- a/native/tests/check_reports.py +++ b/llvm/tests/check_reports.py @@ -27,7 +27,7 @@ def run(*args, **kwargs): 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-provenance', '-S', fixture, '-o', passed) + 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. diff --git a/native/tests/checkpoint.cpp b/llvm/tests/checkpoint.cpp similarity index 100% rename from native/tests/checkpoint.cpp rename to llvm/tests/checkpoint.cpp diff --git a/native/tests/fixture.ll b/llvm/tests/fixture.ll similarity index 100% rename from native/tests/fixture.ll rename to llvm/tests/fixture.ll diff --git a/native/tools/main.cpp b/llvm/tools/main.cpp similarity index 96% rename from native/tools/main.cpp rename to llvm/tools/main.cpp index 778fcce..e1e5f60 100644 --- a/native/tools/main.cpp +++ b/llvm/tools/main.cpp @@ -1,4 +1,4 @@ -#include "compilerlens/Provenance.h" +#include "compilerlens/Lineage.h" #include "llvm/Config/llvm-config.h" #include "llvm/DebugInfo/DWARF/DWARFContext.h" #include "llvm/IR/DiagnosticInfo.h" @@ -71,7 +71,7 @@ int main(int argc, char **argv) { cl::SetVersionPrinter([](raw_ostream &OS) { OS << "compilerlens-native 0.1.0 LLVM " << LLVM_VERSION_STRING << " protocol 1\n"; }); - cl::ParseCommandLineOptions(argc, argv, "CompilerLens native provenance analyzer\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; } @@ -95,7 +95,7 @@ int main(int argc, char **argv) { PB.registerFunctionAnalyses(FAM); PB.registerLoopAnalyses(LAM); PB.crossRegisterProxies(LAM, FAM, CGAM, MAM); ModulePassManager MPM; - MPM.addPass(compilerlens::ProvenancePass(&Report)); + MPM.addPass(compilerlens::LineagePass(&Report)); MPM.run(*M, MAM); if (!Annotated.empty()) { std::error_code EC; diff --git a/pyproject.toml b/pyproject.toml index 31ec4ca..4f30ead 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "compilerlens" version = "0.1.0" -description = "Inspect model compilation and trace compiler-recorded provenance into LLVM and assembly" +description = "Inspect model compilation and trace compiler-recorded lineage into LLVM and assembly" readme = "README.md" license = "Apache-2.0" license-files = ["LICENSE"] diff --git a/scripts/build_release.py b/scripts/build_release.py index 732d48d..d9c4433 100644 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Build native analyzer, existing web app and a platform-specific pip wheel. -Configure build/native with a matching LLVM SDK first, or pass --llvm-dir. +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 @@ -26,11 +26,11 @@ def main(): parser.add_argument('--assets-only', action='store_true') args = parser.parse_args() if args.llvm_dir: - run(args.cmake, '-S', 'native', '-B', 'build/native', '-DCMAKE_BUILD_TYPE=Release', f'-DLLVM_DIR={args.llvm_dir}') - if not (ROOT / 'build/native/CMakeCache.txt').exists(): parser.error('Configure build/native first or supply --llvm-dir.') - run(args.cmake, '--build', 'build/native', '--parallel', '2') - run(str(Path(args.cmake).with_name("ctest")) if "/" in args.cmake else "ctest", "--test-dir", "build/native", "--output-on-failure") - run(args.cmake, '--install', 'build/native', '--prefix', ROOT, '--strip') + 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. @@ -47,7 +47,7 @@ def main(): 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 / 'native/LLVM-LICENSE.txt', licenses / 'LLVM.txt') + 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: diff --git a/tests/test_cli_provenance.py b/tests/test_cli_lineage.py similarity index 90% rename from tests/test_cli_provenance.py rename to tests/test_cli_lineage.py index 8e43f8f..f5d6d23 100644 --- a/tests/test_cli_provenance.py +++ b/tests/test_cli_lineage.py @@ -9,7 +9,7 @@ import unittest from unittest.mock import patch -from compilerlens.provenance import SourceIndex, assembly_locations, analyze_files +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 @@ -94,7 +94,7 @@ 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 / 'provenance.json', {'schema_version': 1, **self.prov}) + 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) @@ -103,6 +103,20 @@ def test_cli_json_and_bad_exit(self): 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): From cd818f28484430b5a884da65da2e1ca33788d76e Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Mon, 28 Sep 2026 02:49:59 +0530 Subject: [PATCH 03/14] compilerlens version upgrade to v0.1.1 release --- README.md | 14 +- compilerlens/__init__.py | 2 +- docs/CLI.md | 14 +- docs/RELEASING.md | 434 +++++++++++---------------------------- docs/releases/0.1.1.md | 47 +++++ llvm/CMakeLists.txt | 2 +- llvm/lib/Lineage.cpp | 2 +- llvm/lib/Plugin.cpp | 2 +- llvm/tools/main.cpp | 2 +- pyproject.toml | 7 +- 10 files changed, 196 insertions(+), 330 deletions(-) create mode 100644 docs/releases/0.1.1.md diff --git a/README.md b/README.md index df87a11..2b24149 100644 --- a/README.md +++ b/README.md @@ -281,9 +281,9 @@ browser viewer. A repository checkout is not required. ### Create an environment and install -Version `0.1.0` is available on **TestPyPI** and has been tested on Linux x86-64 with -Python 3.10 and glibc 2.35+. Production PyPI publication and installation testing are pending, -so use the TestPyPI steps below for now. +Version `0.1.1` targets Linux x86-64 with Python 3.10 and glibc 2.35+. The commands +below install it from **TestPyPI**. Production PyPI publication and installation testing +are pending; see the [release checklist](docs/RELEASING.md) for validation status. ```bash mkdir compilerlens-demo @@ -295,13 +295,13 @@ python -m pip install --upgrade pip # Download CompilerLens from TestPyPI. python -m pip download --no-deps --only-binary=:all: \ --index-url https://test.pypi.org/simple/ \ - --dest wheels compilerlens==0.1.0 + --dest wheels compilerlens==0.1.1 # Install the wheel and resolve its dependencies from the normal indexes. python -m pip install \ --index-url https://pypi.org/simple/ \ --extra-index-url https://download.pytorch.org/whl/cpu \ - wheels/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl + wheels/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl python -m pip check compilerlens doctor @@ -349,8 +349,8 @@ compilerlens compile sshleifer/tiny-gpt2 --seq-len 8 --out runs/tiny-gpt2 See the **[detailed CLI guide](docs/CLI.md)** for selectors, JSON output, offline capture, local Python factories, object-address lookup, and build instructions. The [release checklist](docs/RELEASING.md) records TestPyPI validation and publication steps. -The tested `0.1.0` wheel predates the latest source-directory and terminology changes; -those changes will ship in a subsequent release. +Version `0.1.1` includes the `llvm/` source-directory rename and consistent lineage +terminology, while preserving the ability to read saved runs from version `0.1.0`. ## Installation from Source diff --git a/compilerlens/__init__.py b/compilerlens/__init__.py index 6d850bc..c59726b 100644 --- a/compilerlens/__init__.py +++ b/compilerlens/__init__.py @@ -1,6 +1,6 @@ """CompilerLens: model-aware compiler inspection and lineage.""" -__version__ = "0.1.0" +__version__ = "0.1.1" def capture(*args, **kwargs): diff --git a/docs/CLI.md b/docs/CLI.md index 326d3c0..f20079a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -14,7 +14,7 @@ For a fresh virtual environment and installation from TestPyPI, follow the local wheel build. ```bash -python -m pip install dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl \ +python -m pip install dist/compilerlens-0.1.1-py3-none-linux_x86_64.whl \ --extra-index-url https://download.pytorch.org/whl/cpu compilerlens doctor compilerlens compile --example matmul --out runs/matmul --lineage required @@ -22,10 +22,10 @@ compilerlens inspect runs/matmul compilerlens view runs/matmul ``` -Version 0.1.0 was tested through TestPyPI; production PyPI publication and installation -testing are pending. The local build command above uses the original wheel in `dist/`. -The tested, repaired wheel is -`wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`; the +This guide targets version 0.1.1; production PyPI publication and installation testing +are pending. See the [release checklist](RELEASING.md) for current validation results. The local build command above uses the original wheel in `dist/`. +The repaired release wheel is +`wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl`; the [release checklist](RELEASING.md) explains how to download it from TestPyPI. It bundles the existing React app, Monaco workers, private native executable, LLVM license, zlib and zstd libraries/licenses. @@ -90,7 +90,7 @@ metadata is absent; they cannot recover information the compiler never emitted. Current source uses `lineage` for the analysis module, pass, CLI output fields and sidecar. The reader also accepts the older `provenance.json` sidecar from TestPyPI 0.1.0 runs. -The uploaded wheel retains its original names; these source changes need a new release. +Version 0.1.1 uses the new names. Version 0.1.0 retains its original names. ## Inspect, trace, compare and benchmark @@ -183,7 +183,7 @@ After configuring the native build: ```bash python scripts/build_release.py python -m unittest discover -s tests -v -python -m pip install dist/compilerlens-0.1.0-py3-none-linux_x86_64.whl +python -m pip install dist/compilerlens-0.1.1-py3-none-linux_x86_64.whl python scripts/check_installed.py /path/to/installed/env/bin/python /path/to/run --browser ``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0425372..50139d3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,326 +1,179 @@ -# CompilerLens: TestPyPI to GitHub and PyPI +# Releasing CompilerLens [Project overview](../README.md) · [CLI guide](CLI.md) · [LLVM component](../llvm/README.md) -Current status (28 September 2026): version 0.1.0 has been tested through TestPyPI. -Production PyPI publication and installation testing remain pending. The initial -implementation is committed as `42c45f4` on `feature/experiments-cli`. +## Current release: 0.1.1 -The `llvm/` directory and lineage terminology changes are subsequent source changes. -They are not in the existing TestPyPI wheel. Build and validate a new version to release -these changes; do not overwrite or relabel the existing 0.1.0 artifact. The 0.1.0 commands -below describe the already tested artifact and its publication procedure. +Version 0.1.1 contains the current CLI, LLVM directory and lineage terminology changes, +updated installation documentation, and project URL metadata. See the +[release notes](releases/0.1.1.md) for compatibility details. -Verified on 25 September 2026: +The release wheel is: -- TestPyPI has `compilerlens` version `0.1.0` at https://test.pypi.org/project/compilerlens/0.1.0/. -- Its uploaded wheel's SHA-256 matches the local repaired wheel in `wheelhouse/`. -- The wheel is `compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`. -- GitHub remote: `git@github.com:Soumyaworks/CompilerLens.git`; default branch: `main`. -- Implementation branch: `feature/experiments-cli` (committed after these checks). -- No production PyPI project is publicly visible under `compilerlens` at the time of checking. - TestPyPI does not reserve that name on production PyPI. +```text +wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl +``` -Local prepublication checks for this uploaded wheel are now complete: fresh TestPyPI download, -fresh dependency installation from the normal indexes, `pip check`, required native matmul -capture (33 stages), assembly tracing (208 matches), benchmarking, and the installed browser -flow all ran successfully. All 21 Python tests, native CTest and wheel metadata checks passed. -The benchmark flagged its timings as noisy; it is not a performance result. Generated -artifacts and unrelated experiments were excluded from the subsequent implementation commit. -No production publication was performed during these checks. +It targets Linux x86-64 with glibc 2.35 or newer. Python 3.10 is the validated interpreter. +macOS, Windows and ARM wheels are not provided. The optional full IREE source-build +integration remains unvalidated; the wheel analyzes emitted LLVM snapshots. -Keep the uploaded `0.1.0` wheel unchanged while following these steps. If code, dependencies or -packaged metadata need fixing, use a new version and repeat the TestPyPI checks. Git-only -publication notes and ignore rules can be committed alongside the existing release. +**Status (28 September 2026):** local verification passed: 22 Python tests, native CTest, +TypeScript/viewer build, wheel metadata/platform checks, fresh dependency installation, +`pip check`, installed `doctor`, required-native matmul capture (33 stages), assembly tracing +(208 matches), benchmarking, and installed HTTP/browser checks. Benchmark timings were +flagged as noisy and are not a performance result. -## 1. Start in the repository +TestPyPI 0.1.1 is uploaded and verified. A fresh download matched the local wheel SHA-256. +A separate clean environment installed that downloaded wheel and resolved all dependencies +from PyPI and the PyTorch CPU index. `pip check`, installed-version/import-path checks, +`doctor`, required-native matmul capture (33 stages), forward/reverse tracing (208 assembly +matches), benchmarking, and installed HTTP/browser checks all passed. Production publication +and installation testing remain pending; publish this exact wheel using the commands below. -Use one terminal for the commands so the temporary-directory variables remain available. +Wheel SHA-256: -```bash -cd /local/mnt/workspace/compilers/CompilerLens -CLENS_REPO="$PWD" -git branch --show-current -git status --short +```text +053579416e9c84de30994329018aaf3fd94be905ff0d20770bb14d76998fcc7d ``` -The branch should be `feature/experiments-cli`. Do not use `git add .`: the working directory -also contains unrelated, untracked experimental models and presentation files. +Local build and verification records are retained in `build/release-0.1.1/` (not tracked). -## 2. Install the actual TestPyPI release in a fresh environment +The existing 0.1.0 release remains on TestPyPI. Its historical checks do not validate the +new 0.1.1 wheel. Do not overwrite an uploaded artifact or rebuild it between TestPyPI and +production publication. -This verifies what another user downloads, including resolving real dependencies. Earlier -isolated wheel checks reused local dependency files, so this step still matters. +## Build and check -Use a Linux x86-64 machine with glibc 2.35 or newer. Python 3.10 is the tested interpreter. -The current release does not provide macOS, Windows or ARM wheels. +Use a repository environment with the Python/frontend dependencies installed and the LLVM +build configured as described in the [CLI guide](CLI.md#builddevelop-the-native-pass). +On this workstation: ```bash -CLENS_CHECK_DIR="$(mktemp -d /tmp/compilerlens-release.XXXXXX)" -python3 -m venv --without-pip "$CLENS_CHECK_DIR/venv" - -# Bootstrap pip using the working repository environment. This also works on this -# workstation, where ordinary `python3 -m venv` previously lacked ensurepip. -"$CLENS_REPO/.venv/bin/python" -m pip \ - --python "$CLENS_CHECK_DIR/venv/bin/python" install --upgrade pip - -# Download only CompilerLens from TestPyPI. -"$CLENS_CHECK_DIR/venv/bin/python" -m pip download \ - --index-url https://test.pypi.org/simple/ \ - --no-deps --only-binary=:all: \ - --dest "$CLENS_CHECK_DIR/downloads" \ - compilerlens==0.1.0 - -# Install that downloaded wheel; resolve dependencies from their normal indexes. -"$CLENS_CHECK_DIR/venv/bin/python" -m pip install \ - --index-url https://pypi.org/simple/ \ - --extra-index-url https://download.pytorch.org/whl/cpu \ - "$CLENS_CHECK_DIR/downloads/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl" - -"$CLENS_CHECK_DIR/venv/bin/python" -m pip check +cd /local/mnt/workspace/compilers/CompilerLens +source .venv/bin/activate +python -m unittest discover -s tests -v +python scripts/build_release.py --cmake /pkg/qct/software/cmake/3.31.5/bin/cmake +auditwheel repair dist/compilerlens-0.1.1-py3-none-linux_x86_64.whl --wheel-dir wheelhouse +python -m twine check wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl +auditwheel show wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl +sha256sum wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl ``` -Downloading the package separately keeps TestPyPI out of dependency resolution. Do not treat -an installation with `--no-deps` as a successful test of the complete user installation. - -## 3. Exercise the installed CLI and viewer +The release build runs native CTest, TypeScript checking and the bundled viewer build. +Activating the environment also makes its `patchelf` executable available to auditwheel. +Use the actual platform tag emitted by auditwheel if the build environment changes. +When renaming/removing modules, use a fresh Python package staging tree so obsolete files +in `build/lib.*` cannot enter the wheel. Keep the CMake build in `build/llvm/`. -Run outside the checkout, so Python cannot accidentally import the source directory. - -```bash -cd "$CLENS_CHECK_DIR" -"$CLENS_CHECK_DIR/venv/bin/compilerlens" --version -"$CLENS_CHECK_DIR/venv/bin/compilerlens" doctor +## Upload to TestPyPI -"$CLENS_CHECK_DIR/venv/bin/compilerlens" compile \ - --example matmul --out "$CLENS_CHECK_DIR/matmul" --lineage required +Configure a **TestPyPI** API token in your local keyring or a private `~/.pypirc` entry: -"$CLENS_CHECK_DIR/venv/bin/compilerlens" inspect "$CLENS_CHECK_DIR/matmul" -"$CLENS_CHECK_DIR/venv/bin/compilerlens" inspect "$CLENS_CHECK_DIR/matmul" --show stages -"$CLENS_CHECK_DIR/venv/bin/compilerlens" trace \ - "$CLENS_CHECK_DIR/matmul" --module model --to asm - -"$CLENS_CHECK_DIR/venv/bin/compilerlens" bench \ - "$CLENS_CHECK_DIR/matmul" --workers 2 --repetitions 5 - -"$CLENS_CHECK_DIR/venv/bin/compilerlens" view \ - "$CLENS_CHECK_DIR/matmul" --port 8001 +```ini +[testpypi] +repository = https://test.pypi.org/legacy/ +username = __token__ +password = YOUR_TESTPYPI_TOKEN ``` -Open `http://127.0.0.1:8001`. Select the workload, open its architecture, then open the compiler -pipeline and inspect IR/assembly. On a remote machine, forward port 8001 through SSH to your -browser machine. Press Ctrl+C in the server terminal when finished. - -Expected: `doctor` reports ready, native analysis succeeds, the trace has matching instructions, -and the existing architecture/workspace/editor load. A benchmark can legitimately be marked -unreliable on a busy machine; that is not an installation failure. - -Optional automated browser check, using the existing local Playwright installation: +Keep this file outside the repository and restrict it with `chmod 600 ~/.pypirc`. +Do not put tokens in commands, source files, release logs or screenshots. ```bash -cd "$CLENS_REPO" -.venv/bin/python scripts/check_installed.py \ - "$CLENS_CHECK_DIR/venv/bin/python" "$CLENS_CHECK_DIR/matmul" \ - --browser --browsers-path /tmp/compilerlens-browsers +.venv/bin/python -m twine upload --repository testpypi \ + --repository-url https://test.pypi.org/legacy/ \ + wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl ``` -That browser directory was downloaded during implementation. If it has been removed, install -Playwright's Chromium again as described in [the CLI guide](CLI.md). +## Verify the downloaded release -## 4. Run the source checks and validate the release file +Use a new environment outside the checkout. Do not reuse installed development dependencies +or interpret `--no-deps` installation as a complete installation test. Download CompilerLens +separately from TestPyPI, then resolve dependencies from PyPI and the PyTorch CPU index. ```bash -cd "$CLENS_REPO" -.venv/bin/python -m unittest discover -s tests -v -/pkg/qct/software/cmake/3.31.5/bin/ctest --test-dir build/llvm --output-on-failure -.venv/bin/python -m twine check \ - wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl -``` - -The current suite has 22 Python tests, including old-run compatibility, and the native CTest -integration check. The original 0.1.0 validation ran 21 Python tests. The CTest command -assumes the existing configured native build on this workstation; use [the CLI guide](CLI.md) to build -it on another machine. The release script already runs TypeScript checking when building assets. - -Keep the repaired wheel in `wheelhouse/`. That is the file to publish, not the original generic -`linux_x86_64` wheel in `dist/`. - -## 5. Stage exactly the release's source files - -The initial feature is committed. For follow-up releases, review changes in these paths: - -| Path | Purpose | -|---|---| -| `compilerlens/` | Canonical Python package, CLI, capture/query services, examples and migrated implementation | -| `backend/`, `ingest/`, `models/` | Compatibility imports and removal of the old implementation paths | -| `llvm/` | C++ pass/analyzer, CMake, LLVM license, fixtures and optional IREE hook | -| `pyproject.toml`, `setup.py`, `MANIFEST.in` | Package metadata and wheel/source packaging | -| `frontend/vite.release.config.ts` | Bundle the existing frontend for installation | -| `scripts/build_release.py`, `scripts/check_installed.py`, `scripts/check_viewer.mjs` | Release build and installation/browser validation | -| `scripts/compile_hf_model.py` | Updated imports for the package move | -| `tests/test_architecture.py`, `tests/test_llvm_lineage.py`, `tests/test_cli_lineage.py` | Existing adjusted tests and new regression tests | -| `.gitignore`, `README.md`, [the CLI guide](CLI.md), `docs/RELEASING.md` | Ignore rules and documentation | +CLENS_REPO=/local/mnt/workspace/compilers/CompilerLens +CLENS_CHECK_DIR="$(mktemp -d /tmp/compilerlens-release.XXXXXX)" +python3.10 -m venv --without-pip "$CLENS_CHECK_DIR/venv" +"$CLENS_REPO/.venv/bin/python" -m pip \ + --python "$CLENS_CHECK_DIR/venv/bin/python" install --upgrade pip -For a follow-up commit, stage tracked changes (including the old directory deletions) -and the scoped new source files: +"$CLENS_CHECK_DIR/venv/bin/python" -m pip download \ + --index-url https://test.pypi.org/simple/ --no-deps --only-binary=:all: \ + --dest "$CLENS_CHECK_DIR/downloads" compilerlens==0.1.1 -```bash -cd "$CLENS_REPO" +"$CLENS_CHECK_DIR/venv/bin/python" -m pip install \ + --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu \ + "$CLENS_CHECK_DIR/downloads/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl" -git add -u -- . ':(exclude)**/__pycache__/**' -git add -A -- compilerlens/ backend/ ingest/ models/ llvm/ \ - ':(exclude)**/__pycache__/**' - -git add -- \ - .gitignore README.md \ - pyproject.toml setup.py MANIFEST.in \ - docs/CLI.md docs/RELEASING.md \ - frontend/vite.release.config.ts \ - scripts/build_release.py scripts/check_installed.py scripts/check_viewer.mjs \ - scripts/compile_hf_model.py \ - tests/test_architecture.py tests/test_llvm_lineage.py tests/test_cli_lineage.py - -git diff --cached --check -git diff --cached --stat -git diff --cached --name-status -git status --short +cd "$CLENS_CHECK_DIR" +source venv/bin/activate +python -m pip check +compilerlens --version +compilerlens doctor +compilerlens compile --example matmul --out runs/matmul --lineage required +compilerlens inspect runs/matmul +compilerlens trace runs/matmul --module model --to asm +compilerlens bench runs/matmul --workers 2 --repetitions 5 +compilerlens view runs/matmul --port 8001 ``` -`-A` is scoped to the listed source directories so the package moves include both additions -and removals. The current `.gitignore` excludes build products, including `wheelhouse/`. -The explicit exclusion avoids staging the old tracked bytecode file as part of this release. +Open `http://127.0.0.1:8001`, select the workload and inspect its architecture and compiler +pipeline. Stop the server with Ctrl+C. Timings may be flagged as noisy on a busy host; +that is not an installation failure or a performance result. -Leave these local/generated paths out of the feature commit: - -- `.venv/`, `build/`, `dist/`, `wheelhouse/`, `*.egg-info/`, `node_modules/`, `__pycache__/`. -- `compilerlens/_bin/`, `compilerlens/_web/`, `frontend/public/artifacts/`, `lib/*.so`. -- `runs/`, `jobs/`, `compilerlens-runs/` and generated `.vmfb`, `.bc`, `.o`, `.so` files. -- Currently untracked `archived_models/`, `experiments/`, `presentation/` and the generated - model directories under `examples/`. They are separate work, not required by this package. -- API tokens, `.pypirc` credentials and any environment file containing secrets. - -This does not remove existing tracked example fixtures. Generated native/web assets belong in -the wheel and GitHub Release attachments; their sources and build instructions belong in Git. -Remaining `??` entries for unrelated experiments are expected after staging. - -## 6. Commit and push the feature branch - -After reviewing the staged diff: +Compare the downloaded wheel's SHA-256 with the repaired local wheel. For automated browser +checks, using this workstation's existing Playwright installation: ```bash -git commit -m "Add installable CLI and native LLVM lineage" -git push -u origin feature/experiments-cli +cd "$CLENS_REPO" +.venv/bin/python scripts/check_installed.py \ + "$CLENS_CHECK_DIR/venv/bin/python" "$CLENS_CHECK_DIR/runs/matmul" \ + --browser --browsers-path /tmp/compilerlens-browsers ``` -The code will then be visible on the feature branch at: - -https://github.com/Soumyaworks/CompilerLens/tree/feature/experiments-cli - -Open a pull request into `main`: - -https://github.com/Soumyaworks/CompilerLens/compare/main...feature/experiments-cli - -Suggested title: `Add installable CLI and native LLVM lineage`. - -Describe the packaged CLI, native reporting pass, source/assembly/DWARF tracing, unchanged -viewer and validation results. State that the release wheel targets Linux x86-64/glibc 2.35+, -and that the optional full IREE source build has not been validated. - -Review and merge the PR. If review changes runtime code or package metadata, build and test a -new version before continuing; do not publish the old wheel as if it contained those changes. +## Publish the exact tested wheel to production PyPI -## 7. Publish the tested wheel to production PyPI +Finish TestPyPI verification first. Review and commit the release source changes so that +there is a source revision corresponding to the tested wheel. If runtime code, dependencies +or packaged metadata change, build and test a new version before publishing. -Create/log in to your account at https://pypi.org, verify your email, complete account security -setup and create a **production PyPI** API token at https://pypi.org/manage/account/token/. -For the first upload use an account-wide token; after the project exists, a project-scoped token -can be used. Your TestPyPI token will not work on production PyPI. +Production PyPI requires its own API token. A TestPyPI token does not work there, and +TestPyPI project ownership does not reserve the name on production PyPI. The first public +production release may be 0.1.1; uploading 0.1.0 first is unnecessary. -You may publish the same `0.1.0` artifact to production: the two indexes are independent. +From the repository, verify the saved checksum and upload only this wheel: ```bash -cd "$CLENS_REPO" +cd /local/mnt/workspace/compilers/CompilerLens +(cd wheelhouse && sha256sum -c compilerlens-0.1.1.SHA256SUMS) .venv/bin/python -m twine upload \ --repository-url https://upload.pypi.org/legacy/ \ - wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl -``` - -Enter the production API token when prompted. If Twine asks for a username, use `__token__`. -Keep tokens out of shell commands, Git and screenshots. Explicitly naming the endpoint avoids -accidentally uploading to TestPyPI through a local repository configuration. - -Confirm the new release at https://pypi.org/project/compilerlens/0.1.0/. -If the name cannot be registered, TestPyPI ownership does not grant production ownership; resolve -that before announcing an installation command under this name. - -## 8. Verify installation from production PyPI - -Use another fresh environment, so an already installed TestPyPI package cannot satisfy the request. - -```bash -CLENS_PROD_CHECK="$(mktemp -d /tmp/compilerlens-pypi.XXXXXX)" -python3 -m venv --without-pip "$CLENS_PROD_CHECK/venv" -"$CLENS_REPO/.venv/bin/python" -m pip \ - --python "$CLENS_PROD_CHECK/venv/bin/python" install --upgrade pip - -"$CLENS_PROD_CHECK/venv/bin/python" -m pip install \ - --index-url https://pypi.org/simple/ \ - --extra-index-url https://download.pytorch.org/whl/cpu \ - compilerlens==0.1.0 - -cd "$CLENS_PROD_CHECK" -"$CLENS_PROD_CHECK/venv/bin/python" -m pip check -"$CLENS_PROD_CHECK/venv/bin/compilerlens" doctor -"$CLENS_PROD_CHECK/venv/bin/compilerlens" compile \ - --example matmul --out "$CLENS_PROD_CHECK/matmul" --lineage required + --username __token__ \ + wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl ``` -Once verified, ordinary users can use `pip install compilerlens` on supported systems. The -PyTorch CPU extra index is recommended when explicitly choosing CPU-only PyTorch dependencies. - -## 9. Tag the merged source and create a GitHub Release - -After the PR is merged, update local `main` and inspect the commit being tagged: - -```bash -cd "$CLENS_REPO" -git switch main -git pull --ff-only origin main -git log -1 --oneline -git tag --list v0.1.0 -``` +Enter your **production PyPI token** when prompted. Do not use a wildcard selecting multiple +wheel versions, and do not use the unrepaired wheel from `dist/`. -If the tag does not already exist, and this commit contains the released implementation: +After publication, install in another fresh environment using: ```bash -git tag -a v0.1.0 -m "CompilerLens 0.1.0" -git push origin v0.1.0 +python -m pip install --index-url https://pypi.org/simple/ \ + --extra-index-url https://download.pytorch.org/whl/cpu compilerlens==0.1.1 ``` -If `main` has acquired other runtime changes since your release commit, tag the actual reviewed -release commit instead. Never move an existing published tag to different code. - -On https://github.com/Soumyaworks/CompilerLens/releases/new: - -1. Select tag `v0.1.0` and title `CompilerLens 0.1.0`. -2. Describe the CLI/native pass, supported platform and install command. -3. Link to https://pypi.org/project/compilerlens/0.1.0/ and [the CLI guide](CLI.md). -4. Attach `wheelhouse/compilerlens-0.1.0-py3-none-manylinux_2_35_x86_64.whl`. -5. Publish the release. - -Use the exact tested wheel as the release attachment. GitHub's automatic source archive is -useful for development, but building a wheel from source also requires the native/web build -steps. The binary wheel is the easy installation path. - -Your source appears in the repository and its release appears under **Releases**. Publishing -to PyPI does not automatically create a GitHub Release or a GitHub Packages entry. - -## 10. Make subsequent changes without renaming the project +Repeat `pip check`, `doctor`, matmul compilation, tracing and the viewer check. Then update +the README installation instructions to use production PyPI. Tag the exact released source +commit as `v0.1.1` and create a GitHub Release with the tested wheel and release notes. +Publishing to PyPI does not automatically publish a GitHub Release. -Keep `name = "compilerlens"`, the `compilerlens` import and console command, and the GitHub -repository name. Release new versions instead of overwriting existing files. +## Later releases -For a `0.1.1` release, update the version strings in these six places: +Keep the package name and command `compilerlens`. Publish a new version for subsequent +changes; uploaded distribution files cannot be overwritten. Update version strings in: ```text pyproject.toml project.version @@ -331,49 +184,10 @@ llvm/lib/Plugin.cpp plugin version llvm/lib/Lineage.cpp pass_version ``` -Do not bump artifact/lineage schema versions merely because the package version changed. -Update documentation examples as needed; historical release notes should retain their versions. - -The existing uploaded wheel has no project URL metadata. Add this for the next version in -`pyproject.toml` so the PyPI page links back to GitHub: - -```toml -[project.urls] -Repository = "https://github.com/Soumyaworks/CompilerLens" -Issues = "https://github.com/Soumyaworks/CompilerLens/issues" -Documentation = "https://github.com/Soumyaworks/CompilerLens/blob/main/docs/CLI.md" -``` - -Then build and test that new version on this workstation: - -```bash -cd "$CLENS_REPO" -.venv/bin/python scripts/build_release.py \ - --cmake /pkg/qct/software/cmake/3.31.5/bin/cmake - -.venv/bin/auditwheel repair \ - dist/compilerlens-0.1.1-py3-none-linux_x86_64.whl --wheel-dir wheelhouse - -.venv/bin/python -m twine check \ - wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl - -.venv/bin/python -m twine upload --repository testpypi \ - wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl -``` - -Repeat the clean-install checks, commit/review the source, upload that exact new wheel to -production, and create tag/release `v0.1.1`. Use the actual tag emitted by auditwheel if the -build environment changes. Avoid `wheelhouse/*.whl` once it contains multiple releases. - -Users choose when to upgrade: - -```bash -pip install --upgrade compilerlens -# Or keep a specific release: -pip install compilerlens==0.1.0 -``` +Update installation examples and release notes. Change artifact/sidecar schema versions only +when their formats require it. Document CLI/JSON or Python API compatibility changes. -Older installations are not automatically changed by a new PyPI upload. Keep CLI/Python -interfaces compatible where possible and document breaking changes. Once manual releases -are working, GitHub Actions plus PyPI Trusted Publishing can automate this same tested -sequence without storing a long-lived upload token; that automation is not configured here yet. +Repeat build, repair, testing and publication for the new version. TestPyPI is optional +for later releases, but useful for validating packaging and dependency changes. GitHub Actions +and PyPI Trusted Publishing can automate this workflow later; they are not configured here. +Users choose when to upgrade with `python -m pip install --upgrade compilerlens`. diff --git a/docs/releases/0.1.1.md b/docs/releases/0.1.1.md new file mode 100644 index 0000000..459e350 --- /dev/null +++ b/docs/releases/0.1.1.md @@ -0,0 +1,47 @@ +# 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. + +## 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](../RELEASING.md) for validation and publication status and +the [CLI guide](../CLI.md) for usage. + +## 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 installation +testing remain pending. diff --git a/llvm/CMakeLists.txt b/llvm/CMakeLists.txt index 822ebef..63e8674 100644 --- a/llvm/CMakeLists.txt +++ b/llvm/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(CompilerLensLLVM VERSION 0.1.0 LANGUAGES C CXX) +project(CompilerLensLLVM VERSION 0.1.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) diff --git a/llvm/lib/Lineage.cpp b/llvm/lib/Lineage.cpp index 6e1bcd0..27f1716 100644 --- a/llvm/lib/Lineage.cpp +++ b/llvm/lib/Lineage.cpp @@ -88,7 +88,7 @@ json::Object analyze(Module &M) { 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.1.0"}, {"llvm_version", LLVM_VERSION_STRING}, + return json::Object{{"schema_version", 1}, {"pass_version", "0.1.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}}}}; diff --git a/llvm/lib/Plugin.cpp b/llvm/lib/Plugin.cpp index 9ed144e..fb49fee 100644 --- a/llvm/lib/Plugin.cpp +++ b/llvm/lib/Plugin.cpp @@ -2,7 +2,7 @@ #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.1.0", [](llvm::PassBuilder &PB) { + return {LLVM_PLUGIN_API_VERSION, "CompilerLensPasses", "0.1.1", [](llvm::PassBuilder &PB) { PB.registerPipelineParsingCallback([](llvm::StringRef Name, llvm::ModulePassManager &MPM, llvm::ArrayRef) { if (Name != "compilerlens-lineage") return false; diff --git a/llvm/tools/main.cpp b/llvm/tools/main.cpp index e1e5f60..f9d601e 100644 --- a/llvm/tools/main.cpp +++ b/llvm/tools/main.cpp @@ -69,7 +69,7 @@ static Expected objectReport() { int main(int argc, char **argv) { InitLLVM Init(argc, argv); cl::SetVersionPrinter([](raw_ostream &OS) { - OS << "compilerlens-native 0.1.0 LLVM " << LLVM_VERSION_STRING << " protocol 1\n"; + OS << "compilerlens-native 0.1.1 LLVM " << LLVM_VERSION_STRING << " protocol 1\n"; }); cl::ParseCommandLineOptions(argc, argv, "CompilerLens native lineage analyzer\n"); json::Object Report; diff --git a/pyproject.toml b/pyproject.toml index 4f30ead..22c66cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "compilerlens" -version = "0.1.0" +version = "0.1.1" description = "Inspect model compilation and trace compiler-recorded lineage into LLVM and assembly" readme = "README.md" license = "Apache-2.0" @@ -16,6 +16,11 @@ dependencies = [ "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" From 7b3ec2e289a5c1dc511c1629b9236ff8e1daa8e8 Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Mon, 28 Sep 2026 03:01:58 +0530 Subject: [PATCH 04/14] PyPI release and readme updates --- README.md | 27 ++++++++++++------------- docs/CLI.md | 17 +++++++--------- docs/RELEASING.md | 45 ++++++++++++++++++++++++++++++++++++++++-- docs/releases/0.1.1.md | 41 ++++++++++++++++++++++++++++++++++---- 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2b24149..5ddfdc1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # CompilerLens +[![PyPI version](https://img.shields.io/pypi/v/compilerlens?color=38bdf8)](https://pypi.org/project/compilerlens/) +[![Tested with Python 3.10](https://img.shields.io/badge/Python-3.10_tested-a78bfa)](#system-requirements) +[![Platform: Linux x86-64](https://img.shields.io/badge/platform-Linux_x86--64-34d399)](#system-requirements) +[![License: Apache 2.0](https://img.shields.io/badge/license-Apache_2.0-fbbf24)](LICENSE) + CompilerLens is an interactive explorer for AI compiler pipelines. It compiles a PyTorch or Hugging Face model through IREE and presents the resulting Torch, MLIR, LLVM IR, and x86-64 stages in one navigable interface. @@ -281,9 +286,8 @@ browser viewer. A repository checkout is not required. ### Create an environment and install -Version `0.1.1` targets Linux x86-64 with Python 3.10 and glibc 2.35+. The commands -below install it from **TestPyPI**. Production PyPI publication and installation testing -are pending; see the [release checklist](docs/RELEASING.md) for validation status. +Version [`0.1.1`](https://pypi.org/project/compilerlens/0.1.1/) is available on **PyPI** +for Linux x86-64 with glibc 2.35+. Python 3.10 is the validated interpreter. ```bash mkdir compilerlens-demo @@ -292,24 +296,19 @@ python3.10 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip -# Download CompilerLens from TestPyPI. -python -m pip download --no-deps --only-binary=:all: \ - --index-url https://test.pypi.org/simple/ \ - --dest wheels compilerlens==0.1.1 - -# Install the wheel and resolve its dependencies from the normal indexes. +# Install CompilerLens from PyPI, using CPU builds of PyTorch. python -m pip install \ --index-url https://pypi.org/simple/ \ --extra-index-url https://download.pytorch.org/whl/cpu \ - wheels/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl + compilerlens==0.1.1 python -m pip check compilerlens doctor ``` -Downloading the package separately keeps TestPyPI out of dependency resolution. The PyTorch -CPU index supplies CPU builds. Once a production release is published, installation can use -`python -m pip install compilerlens` with the documented dependency indexes. +The PyTorch CPU index supplies CPU builds for the supported workflow. CompilerLens itself +comes from PyPI. See the [release notes](docs/releases/0.1.1.md) for compatibility and +the [release checklist](docs/RELEASING.md) for validation details. ### Compile, inspect, and open the viewer @@ -348,7 +347,7 @@ compilerlens compile sshleifer/tiny-gpt2 --seq-len 8 --out runs/tiny-gpt2 See the **[detailed CLI guide](docs/CLI.md)** for selectors, JSON output, offline capture, local Python factories, object-address lookup, and build instructions. The -[release checklist](docs/RELEASING.md) records TestPyPI validation and publication steps. +[release checklist](docs/RELEASING.md) records PyPI validation and publication steps. Version `0.1.1` includes the `llvm/` source-directory rename and consistent lineage terminology, while preserving the ability to read saved runs from version `0.1.0`. diff --git a/docs/CLI.md b/docs/CLI.md index f20079a..8d5a6eb 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -7,14 +7,13 @@ The `feature/experiments-cli` branch provides a complete Python package, the exi a standalone LLVM analyzer and a loadable LLVM New Pass Manager plugin. The packaged analyzer runs on actual IREE output. Source attribution and LLVM def-use edges are separate relationships. -## Install the built wheel +## Install from PyPI -For a fresh virtual environment and installation from TestPyPI, follow the -[CLI quick start](../README.md#cli-quick-start). The commands below assume an existing -local wheel build. +For a fresh virtual environment, follow the [CLI quick start](../README.md#cli-quick-start). +Inside that environment, install the published package: ```bash -python -m pip install dist/compilerlens-0.1.1-py3-none-linux_x86_64.whl \ +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 @@ -22,11 +21,9 @@ compilerlens inspect runs/matmul compilerlens view runs/matmul ``` -This guide targets version 0.1.1; production PyPI publication and installation testing -are pending. See the [release checklist](RELEASING.md) for current validation results. The local build command above uses the original wheel in `dist/`. -The repaired release wheel is -`wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl`; the -[release checklist](RELEASING.md) explains how to download it from TestPyPI. +This guide targets [version 0.1.1 on PyPI](https://pypi.org/project/compilerlens/0.1.1/). +See the [release checklist](RELEASING.md) for validation results and local wheel builds. +The repaired release wheel is `compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl`. It bundles the existing React app, Monaco workers, private native executable, LLVM license, zlib and zstd libraries/licenses. End users do not need Node, CMake, `opt` or an LLVM SDK. Dependencies include the validated diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 50139d3..ccbdf1f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -28,8 +28,17 @@ TestPyPI 0.1.1 is uploaded and verified. A fresh download matched the local whee A separate clean environment installed that downloaded wheel and resolved all dependencies from PyPI and the PyTorch CPU index. `pip check`, installed-version/import-path checks, `doctor`, required-native matmul capture (33 stages), forward/reverse tracing (208 assembly -matches), benchmarking, and installed HTTP/browser checks all passed. Production publication -and installation testing remain pending; publish this exact wheel using the commands below. +matches), benchmarking, and installed HTTP/browser checks all passed. + +[Production PyPI 0.1.1](https://pypi.org/project/compilerlens/0.1.1/) is published and +verified. The production file and fresh installation download match the same SHA-256. +A new environment outside the checkout resolved all dependencies from PyPI and the PyTorch +CPU index, then passed `pip check`, installed-version/import-path checks, `doctor`, required-native +matmul capture (33 stages), forward/reverse tracing (208 assembly matches), benchmarking, +and HTTP/browser checks covering workload cards, the workspace, Monaco and its worker. +The production benchmark passed its reliability check; these smoke tests are not a +performance comparison. The source branch and `v0.1.1` tag are pushed; creating the GitHub +Release is the remaining publication step. Wheel SHA-256: @@ -170,6 +179,38 @@ the README installation instructions to use production PyPI. Tag the exact relea commit as `v0.1.1` and create a GitHub Release with the tested wheel and release notes. Publishing to PyPI does not automatically publish a GitHub Release. +## GitHub release from the development branch + +Version `0.1.1` was published from `feature/experiments-cli`. The pushed tag `v0.1.1` +points to release commit `cd818f28484430b5a884da65da2e1ca33788d76e`. Keep that tag on +the released source commit when committing later documentation updates. Merging into +`main` is not required: GitHub Releases belong to the repository and reference a tag. + +After production installation verification: + +1. Review, commit and push the publication documentation on `feature/experiments-cli`. +2. Open [New release](https://github.com/Soumyaworks/CompilerLens/releases/new), select + the **existing** `v0.1.1` tag, and set the title to **CompilerLens 0.1.1**. +3. Paste the updated [release notes](releases/0.1.1.md) into the description. Attach + `wheelhouse/compilerlens-0.1.1-py3-none-manylinux_2_35_x86_64.whl` and + `wheelhouse/compilerlens-0.1.1.SHA256SUMS`. +4. Publish the release. A feature branch does not itself require GitHub's pre-release + checkbox; use it only when intentionally labeling the release as a preview. +5. In the repository's **About** settings, set the website to + `https://pypi.org/project/compilerlens/` so visitors can find the package. + +The GitHub landing page displays the default branch's README. Documentation pushed only +to `feature/experiments-cli` is visible when that branch is selected; it will reach the +default branch when merged. The published release remains visible repository-wide. + +The README includes PyPI version, tested Python, platform and license badges. A build +status badge should be added when a real GitHub Actions workflow exists. No CI workflow +or automated publishing is configured yet. + +PyPI's project description is embedded in the uploaded wheel's metadata. Updating the +GitHub README does not update the `0.1.1` PyPI description. Include the revised README +in the next version (for example, `0.1.2`); do not rebuild or replace the published wheel. + ## Later releases Keep the package name and command `compilerlens`. Publish a new version for subsequent diff --git a/docs/releases/0.1.1.md b/docs/releases/0.1.1.md index 459e350..a0e4d6d 100644 --- a/docs/releases/0.1.1.md +++ b/docs/releases/0.1.1.md @@ -4,6 +4,22 @@ Version 0.1.1 packages the latest CLI, LLVM analysis, and documentation changes 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/`. @@ -28,8 +44,10 @@ Integrations using Python internals should import `compilerlens.lineage` instead 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](../RELEASING.md) for validation and publication status and -the [CLI guide](../CLI.md) for usage. +See the [release checklist](https://github.com/Soumyaworks/CompilerLens/blob/feature/experiments-cli/docs/RELEASING.md) +for validation and publication status and the +[CLI guide](https://github.com/Soumyaworks/CompilerLens/blob/feature/experiments-cli/docs/CLI.md) +for usage. ## Validation status — 28 September 2026 @@ -43,5 +61,20 @@ TestPyPI 0.1.1 was uploaded and freshly downloaded. Its SHA-256 matches the repa 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 installation -testing remain pending. +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 +``` From f5512847e9e16d8c682630e0dbc537d356b091ce Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Mon, 28 Sep 2026 03:22:13 +0530 Subject: [PATCH 05/14] Update badge versions --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5ddfdc1..1dee276 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # CompilerLens -[![PyPI version](https://img.shields.io/pypi/v/compilerlens?color=38bdf8)](https://pypi.org/project/compilerlens/) -[![Tested with Python 3.10](https://img.shields.io/badge/Python-3.10_tested-a78bfa)](#system-requirements) -[![Platform: Linux x86-64](https://img.shields.io/badge/platform-Linux_x86--64-34d399)](#system-requirements) -[![License: Apache 2.0](https://img.shields.io/badge/license-Apache_2.0-fbbf24)](LICENSE) +

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

CompilerLens is an interactive explorer for AI compiler pipelines. It compiles a PyTorch or Hugging Face model through IREE and presents the resulting Torch, MLIR, LLVM IR, and x86-64 From 49f6e5c85155af4528293a71bb48e5baf29be893 Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Mon, 28 Sep 2026 17:14:09 +0530 Subject: [PATCH 06/14] Add CI and automated PyPI publishing workflows --- .github/workflows/ci.yml | 75 +++++++++++++++++++++ .github/workflows/publish.yml | 122 ++++++++++++++++++++++++++++++++++ docs/RELEASING.md | 49 ++++++++++++-- 3 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f3fd18b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: + - main + - feature/experiments-cli + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python 3.10 tests + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install project dependencies + run: | + python -m pip install --upgrade pip tomli + python - <<'PY' + import subprocess + import sys + import tomli + + with open("pyproject.toml", "rb") as source: + dependencies = tomli.load(source)["project"]["dependencies"] + + subprocess.check_call([ + sys.executable, + "-m", + "pip", + "install", + "--extra-index-url", + "https://download.pytorch.org/whl/cpu", + *dependencies, + ]) + PY + + - name: Run Python tests + run: python -m unittest discover -s tests -v + + frontend: + name: Frontend build + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Type-check and build frontend + run: npm run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6f7a17a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,122 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + name: Build and verify Linux wheel + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Python build tools + run: python -m pip install --upgrade pip "setuptools>=77" wheel auditwheel twine tomli + + - name: Check tag matches package version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + python - <<'PY' + import os + import re + import tomli + + with open("pyproject.toml", "rb") as source: + version = tomli.load(source)["project"]["version"] + + tag = os.environ["RELEASE_TAG"] + if not re.fullmatch(r"v\d+\.\d+\.\d+", tag): + raise SystemExit(f"Release tag must have the form vX.Y.Z, got {tag!r}") + if tag != f"v{version}": + raise SystemExit(f"Tag {tag!r} does not match project version {version!r}") + PY + + - name: Install LLVM 22 and build tools + run: | + wget --quiet https://apt.llvm.org/llvm.sh + chmod +x llvm.sh + sudo ./llvm.sh 22 + sudo apt-get update + sudo apt-get install --yes llvm-22-dev libzstd-dev zlib1g-dev patchelf + npm ci --prefix frontend + + - name: Install project dependencies and run Python tests + run: | + python - <<'PY' + import subprocess + import sys + import tomli + + with open("pyproject.toml", "rb") as source: + dependencies = tomli.load(source)["project"]["dependencies"] + + subprocess.check_call([ + sys.executable, + "-m", + "pip", + "install", + "--extra-index-url", + "https://download.pytorch.org/whl/cpu", + *dependencies, + ]) + PY + python -m unittest discover -s tests -v + + - name: Build and test release wheel + run: | + python scripts/build_release.py --llvm-dir /usr/lib/llvm-22/lib/cmake/llvm + auditwheel repair dist/compilerlens-*.whl --wheel-dir wheelhouse + python -m twine check wheelhouse/*.whl + auditwheel show wheelhouse/*.whl + + - name: Smoke-test installed wheel + run: | + python -m venv /tmp/compilerlens-wheel-test + /tmp/compilerlens-wheel-test/bin/python -m pip install --no-deps wheelhouse/*.whl + cd /tmp + /tmp/compilerlens-wheel-test/bin/compilerlens --version + + - uses: actions/upload-artifact@v4 + with: + name: pypi-wheel + path: wheelhouse/*.whl + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish trusted release + needs: build + runs-on: ubuntu-22.04 + environment: + name: pypi + url: https://pypi.org/project/compilerlens/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: pypi-wheel + path: wheelhouse + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: wheelhouse/ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ccbdf1f..974f04e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -145,6 +145,43 @@ cd "$CLENS_REPO" ## Publish the exact tested wheel to production PyPI +### Automated releases after 0.1.1 + +GitHub Actions now runs Python tests and the frontend build for pushes to `main` and +`feature/experiments-cli`, for pull requests, and on manual dispatch. The publishing +workflow runs only when a tag matching `v*` is pushed. It rejects tags that are not exactly +`vX.Y.Z` or do not match `project.version` in `pyproject.toml`, then builds, tests, repairs +and checks the Linux wheel before publishing it. + +Before the first automated publication, configure PyPI Trusted Publishing for the +`compilerlens` project with these values: + +```text +Owner: Soumyaworks +Repository: CompilerLens +Workflow: publish.yml +Environment: pypi +``` + +Create the `pypi` environment under the GitHub repository's **Settings → Environments**. +Environment reviewers may be added when a manual approval before publication is desired. +The workflow uses GitHub's short-lived OpenID Connect credential, so it does not require a +PyPI API token in GitHub secrets. + +For a later version, update all version locations listed below, commit the release source +including `.github/workflows/publish.yml`, and push the commit. After its CI run passes, +tag that exact commit and push the tag: + +```bash +git tag -a v0.1.2 -m "CompilerLens 0.1.2" +git push origin v0.1.2 +``` + +Do not reuse or move a published version tag. PyPI distributions are immutable, and pushing +an existing version again will fail. Creating a GitHub Release remains a separate step. + +### Manual 0.1.1 procedure + Finish TestPyPI verification first. Review and commit the release source changes so that there is a source revision corresponding to the tested wheel. If runtime code, dependencies or packaged metadata change, build and test a new version before publishing. @@ -203,9 +240,9 @@ The GitHub landing page displays the default branch's README. Documentation push to `feature/experiments-cli` is visible when that branch is selected; it will reach the default branch when merged. The published release remains visible repository-wide. -The README includes PyPI version, tested Python, platform and license badges. A build -status badge should be added when a real GitHub Actions workflow exists. No CI workflow -or automated publishing is configured yet. +The README includes PyPI version, tested Python, platform and license badges. CI and +tag-based PyPI publishing are configured in `.github/workflows/`. The publishing workflow +applies to versions released after the manually published 0.1.1 package. PyPI's project description is embedded in the uploaded wheel's metadata. Updating the GitHub README does not update the `0.1.1` PyPI description. Include the revised README @@ -228,7 +265,7 @@ llvm/lib/Lineage.cpp pass_version Update installation examples and release notes. Change artifact/sidecar schema versions only when their formats require it. Document CLI/JSON or Python API compatibility changes. -Repeat build, repair, testing and publication for the new version. TestPyPI is optional -for later releases, but useful for validating packaging and dependency changes. GitHub Actions -and PyPI Trusted Publishing can automate this workflow later; they are not configured here. +Repeat build, repair and testing for the new version. TestPyPI is optional for later +releases, but useful for validating packaging and dependency changes. A matching `vX.Y.Z` +tag starts the GitHub Actions build and PyPI Trusted Publishing workflow described above. Users choose when to upgrade with `python -m pip install --upgrade compilerlens`. From 1b65a69dadfcbf249926cb6ac0425aeca7d8c3f5 Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Mon, 28 Sep 2026 20:28:40 +0530 Subject: [PATCH 07/14] Adding SSH tunneling details --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index 1dee276..4e2d7ad 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,8 @@ compilerlens view runs/matmul --port 8000 Open `http://127.0.0.1:8000` to explore the captured model architecture, compiler pipeline, and operation lineage. Stop the viewer with Ctrl+C. Choose a new output directory for each capture; CompilerLens refuses to overwrite an existing run. +If CompilerLens is running on a remote SSH server, use the +[SSH tunnel instructions below](#viewing-from-a-remote-ssh-server) to open it on your computer. To capture a Hugging Face model: @@ -334,6 +336,34 @@ To capture a Hugging Face model: compilerlens compile sshleifer/tiny-gpt2 --seq-len 8 --out runs/tiny-gpt2 ``` +### Viewing from a remote SSH server + +Use **SSH tunneling (local port forwarding)** to reach the remote viewer from your own +computer. `127.0.0.1` refers to the machine where it is used: opening that address in your +local browser does not directly connect to the remote server. + +1. On the **remote server**, start the viewer and leave it running: + + ```bash + compilerlens view runs/matmul --port 8000 + ``` + +2. In a separate terminal on **your own computer**, start the tunnel: + + ```bash + ssh -N -L 8001:127.0.0.1:8000 username@remote-host + ``` + + Replace `username@remote-host` with your usual SSH destination or configured SSH alias. + This forwards local port `8001` to port `8000` on the remote server. + +3. Open **http://127.0.0.1:8001** in your computer's browser to view the saved run. + +Keep both the viewer and SSH tunnel running while using the webpage; Ctrl+C stops each +process. If local port `8001` is occupied, choose another local port in the tunnel command +and browser URL. `--open` attempts to launch a browser on the machine running CompilerLens; +it does not open your local browser through SSH. + ### Primary commands | Command | Purpose | @@ -396,6 +426,18 @@ Open the **Local** URL printed by Vite after `npm run dev` (for example, Keep both processes running while using model search or the Compiler Playground. The existing, pre-generated workload viewer only needs the frontend. +When running this source-development setup on a remote SSH server, start both processes +there, then forward the frontend and API ports from a terminal on **your own computer**: + +```bash +ssh -N -L 5173:127.0.0.1:5173 -L 8000:127.0.0.1:8000 username@remote-host +``` + +Replace the SSH destination with your own and open `http://127.0.0.1:5173` locally. +Use Vite's actual remote port if it selected a different one. Keep local port `8000` +forwarded for model search and the Compiler Playground, which use the API on that port. +For the installed CLI viewer, use the [single-port tunnel above](#viewing-from-a-remote-ssh-server). + If port 8000 is already in use, an API server is probably running in another terminal. Reuse that process or stop it before starting another one. @@ -510,6 +552,9 @@ npm run verify ## Troubleshooting +- Remote viewer starts but the browser cannot connect: follow the + [SSH tunnel instructions](#viewing-from-a-remote-ssh-server), open the local forwarded URL, + and keep both the viewer and tunnel running. - `iree-compile not found`: activate `.venv` before starting the API or compiler script. - API unavailable in the UI: confirm `python -m backend.api.run_server` is listening on port 8000. From 95fb78607784cb690d72b25b46ac5d797bfc7518 Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Tue, 29 Sep 2026 01:59:53 +0530 Subject: [PATCH 08/14] Integrate native lineage into source workflows and add Conda setup --- .condarc | 5 ++ .github/workflows/publish.yml | 1 + .gitignore | 2 + README.md | 99 ++++++++++++++++++++++++++----- compilerlens/backend/api/app.py | 23 +++++-- compilerlens/ingest/build.py | 27 ++++++++- docs/CLI.md | 25 +++++++- environment.yml | 21 +++++++ frontend/src/api/sandbox.ts | 7 ++- llvm/README.md | 11 ++++ tests/test_explore_lineage.py | 76 ++++++++++++++++++++++++ tests/test_source_artifacts.py | 102 ++++++++++++++++++++++++++++++++ 12 files changed, 373 insertions(+), 26 deletions(-) create mode 100644 .condarc create mode 100644 environment.yml create mode 100644 tests/test_explore_lineage.py create mode 100644 tests/test_source_artifacts.py diff --git a/.condarc b/.condarc new file mode 100644 index 0000000..03dd10b --- /dev/null +++ b/.condarc @@ -0,0 +1,5 @@ +# Use only conda-forge for this project, without changing the user's global config. +channels: #!final + - conda-forge +default_channels: [] #!final +channel_priority: strict diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6f7a17a..40cbf4c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -83,6 +83,7 @@ jobs: - name: Build and test release wheel run: | python scripts/build_release.py --llvm-dir /usr/lib/llvm-22/lib/cmake/llvm + python -m unittest discover -s tests -p test_source_artifacts.py -v auditwheel repair dist/compilerlens-*.whl --wheel-dir wheelhouse python -m twine check wheelhouse/*.whl auditwheel show wheelhouse/*.whl diff --git a/.gitignore b/.gitignore index 9881625..390737d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ wheelhouse/ # Generated artifacts -- rebuild with `npm run artifact` (see README) frontend/public/artifacts/ +examples/*/native/ +examples/*/lineage.json # Verification output, regenerable via `npm run verify` frontend/screenshots/ diff --git a/README.md b/README.md index 4e2d7ad..a2d8ea1 100644 --- a/README.md +++ b/README.md @@ -278,8 +278,10 @@ CompilerLens/ - For frontend development from source: Node.js 20.19+ or 22.12+; Node 22 is recommended The package includes the IREE compiler/runtime dependencies and a prebuilt LLVM analyzer. -The installed CLI and bundled viewer do not require Node.js, CMake, or an LLVM SDK. Building -that analyzer from source requires the tools listed in the [CLI development guide](docs/CLI.md#builddevelop-the-native-pass). +The installed CLI and bundled viewer do not require Node.js, CMake, or an LLVM SDK. +Source installation builds our LLVM pass. The [Miniconda setup below](#installation-from-source) +provides Python, Node.js, CMake, Ninja, a C++ compiler and LLVM in one user-local environment; +no sudo or separately installed LLVM/CMake is needed. ## CLI Quick Start @@ -385,38 +387,100 @@ terminology, while preserving the ability to read saved runs from version `0.1.0 ## Installation from Source -Use this workflow to develop CompilerLens or run the frontend directly from the checkout. -For the packaged CLI and viewer, follow the [CLI quick start](#cli-quick-start). +This path builds the Python/frontend application **and our native LLVM pass** from the +checkout. For the prebuilt application, use the [PyPI quick start](#cli-quick-start). -From the repository root: +Use **Linux x86-64 with glibc 2.35+** (for example, Ubuntu 22.04), internet access, and +several GB of free disk space. No sudo or GPU is required. [environment.yml](environment.yml) +provides Python 3.10, LLVM 22.1.8, the C/C++ build tools and Node.js 22; `requirements.txt` +provides the Python packages. Conda replaces the separate venv for source development. + +### 1. Install Miniconda once + +Skip installation if you already have Conda. Otherwise, install it under your own account: ```bash -python3.10 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip +curl -fsSLo /tmp/compilerlens-miniconda.sh \ + https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh +bash /tmp/compilerlens-miniconda.sh -b -p "$HOME/miniconda3" +source "$HOME/miniconda3/etc/profile.d/conda.sh" +``` + +If curl is unavailable, download that installer using a browser. In new terminals, repeat +the `source` command to enable `conda activate` (adjust the path for an existing installation). +If a Python venv is currently active, run `deactivate` before activating Conda. + +### 2. Create the development environment + +From your updated CompilerLens checkout (`feature/experiments-cli` until merged): + +```bash +CONDARC="$PWD/.condarc" conda env create -f environment.yml +conda activate compilerlens-dev python -m pip install -r requirements.txt +python -m pip check +``` + +The project-local `.condarc` selects conda-forge without changing global Conda settings. +The environment excludes unrelated user-site Python packages. Do not create another venv +or install CompilerLens from PyPI inside this environment. +If you do not have the checkout or Git yet, use GitHub's **Code → Download ZIP** on the +development branch and extract it first; the environment also installs Git for later use. + +### 3. Build the pass and prepare the webpage + +From the repository root with `compilerlens-dev` active: + +```bash +cmake -S llvm -B build/llvm-conda -G Ninja \ + -DLLVM_DIR="$CONDA_PREFIX/lib/cmake/llvm" \ + -DPython3_EXECUTABLE="$CONDA_PREFIX/bin/python" \ + -DCMAKE_BUILD_TYPE=Release +cmake --build build/llvm-conda --parallel 2 +ctest --test-dir build/llvm-conda --output-on-failure +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" +"$COMPILERLENS_NATIVE" --version cd frontend npm ci -npm run artifact +npm run artifact -- --lineage required cd .. ``` -`npm run artifact` converts the compiler dumps under `examples/` into the JSON artifacts used -by the website. Run it again after changing ingest code or adding a model from the command line. +Conda activation selects the environment's compiler; `$CONDA_PREFIX` locates its LLVM SDK. +The separate `build/llvm-conda/` directory avoids old system-toolchain CMake caches. +CTest should pass, and artifact generation should report `Lineage: native`. +Use `--parallel 1` if the native build runs out of memory. Keep the Conda environment active +when running the analyzer: it uses libraries from that environment. + +Artifact generation updates the existing `examples/` workloads using their saved dumps, +without recompiling models or modifying original LLVM files. Repeat the build after C++ +changes, then repeat `npm run artifact -- --lineage required` from `frontend/` and refresh +the page. Native reports go into each workload's `native/` directory, mappings into +`lineage.json`, and viewer JSON into `frontend/public/artifacts/`. +Separate CLI captures under `runs/` are not automatically included. + +Lineage links source operations to LLVM and assembly instructions. `required` fails if native +analysis is unavailable or fails. Plain `npm run artifact` uses Automatic with fallback; +`npm run artifact -- --lineage off` skips native analysis. New Web Explore compilations +automatically use native analysis when available, with fallback if it is unavailable or +fails; viewing saved workloads does not rerun analysis. Missing compiler source locations +can still leave instructions unlinked. ## Running CompilerLens Locally -Start the API from the repository root: +After completing the source installation above, start the API from the repository root: ```bash -source .venv/bin/activate +conda activate compilerlens-dev +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" python -m backend.api.run_server ``` In a second terminal, start the frontend: ```bash +conda activate compilerlens-dev cd frontend npm run dev ``` @@ -492,7 +556,8 @@ For the installed package, use the [CLI quick start](#cli-quick-start). The repo provides a developer script for generating dumps and rebuilding the static artifact library: ```bash -source .venv/bin/activate +conda activate compilerlens-dev +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" python scripts/compile_hf_model.py prajjwal1/bert-tiny --seq-len 16 cd frontend npm run artifact @@ -534,7 +599,8 @@ default; `COMPILERLENS_WORKSPACE` overrides it. Run the backend syntax checks and frontend production build: ```bash -source .venv/bin/activate +conda activate compilerlens-dev +export COMPILERLENS_NATIVE="$PWD/build/llvm-conda/compilerlens-native" python -m py_compile compilerlens/backend/api/app.py compilerlens/backend/api/run_server.py compilerlens/ingest/build.py compilerlens/ingest/schema.py compilerlens/models/architecture.py compilerlens/ingest/architecture.py python -m unittest discover -s tests -v @@ -555,7 +621,8 @@ npm run verify - Remote viewer starts but the browser cannot connect: follow the [SSH tunnel instructions](#viewing-from-a-remote-ssh-server), open the local forwarded URL, and keep both the viewer and tunnel running. -- `iree-compile not found`: activate `.venv` before starting the API or compiler script. +- `iree-compile not found`: activate `compilerlens-dev` for source development, or `.venv` + for the PyPI quick start, before starting the API or compiler script. - API unavailable in the UI: confirm `python -m backend.api.run_server` is listening on port 8000. - Hugging Face download failure: check the model ID, network access, authentication for gated diff --git a/compilerlens/backend/api/app.py b/compilerlens/backend/api/app.py index 1f2b872..565db63 100644 --- a/compilerlens/backend/api/app.py +++ b/compilerlens/backend/api/app.py @@ -26,6 +26,7 @@ import threading import uuid from pathlib import Path +from typing import Literal from fastapi import BackgroundTasks, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware @@ -134,6 +135,7 @@ class ExploreRequest(BaseModel): model_id: str = Field(min_length=1, max_length=200) seq_len: int = Field(default=16, ge=1, le=512) + lineage: Literal['auto', 'required', 'off'] = 'auto' class BenchmarkRequest(BaseModel): @@ -237,15 +239,16 @@ def explore_model(request: ExploreRequest, background: BackgroundTasks): job_id = str(uuid.uuid4())[:8] JOBS[job_id] = { "status": "running", "model_id": model_id, "seq_len": request.seq_len, + "lineage": request.lineage, "options": {}, "flags": [], "stages": {}, "bench": None, "error": None, "compile_seconds": None, "work_dir": None, "artifact_id": None, "progress": None, } - background.add_task(_run_explore, job_id, model_id, request.seq_len) + background.add_task(_run_explore, job_id, model_id, request.seq_len, request.lineage) return {"job_id": job_id, "status": "running"} -def _run_explore(job_id: str, model_id: str, seq_len: int) -> None: +def _run_explore(job_id: str, model_id: str, seq_len: int, lineage: str = 'auto') -> None: """Use the same durable capture and native lineage pipeline as the CLI.""" from compilerlens.services import capture job = JOBS[job_id] @@ -253,11 +256,14 @@ def _run_explore(job_id: str, model_id: str, seq_len: int) -> None: phase = "loading and compiling the model" try: def report(label): - previous = job.get('progress', {}).get('done', 0) + nonlocal phase + phase = label + previous = (job.get('progress') or {}).get('done', 0) job['progress'] = {'label': label, 'done': previous + 1, 'total': max(previous + 2, 24)} output_dir = capture(model_id, seq_len=seq_len, out=REPO_ROOT / 'runs' / job_id, - max_parameters=_MAX_EXPLORE_PARAMETERS, progress=report) + max_parameters=_MAX_EXPLORE_PARAMETERS, progress=report, + lineage=lineage) info = json.loads((output_dir / 'model_info.json').read_text()) artifact = json.loads((output_dir / 'artifact.json').read_text()) artifact_id = artifact['compilation_id'] @@ -318,6 +324,14 @@ def _friendly_explore_error(exc: Exception, model_id: str, phase: str) -> str: lower = raw.lower() kind = type(exc).__name__.lower() + if phase.startswith('Native lineage:') or any(token in lower for token in ( + 'native analyzer', 'native analysis', 'required native', + )): + return ( + 'Native lineage analysis could not complete. Check the native analyzer on the server, ' + 'or choose Automatic or Off to compile without requiring native analysis.' + ) + if isinstance(exc, ExploreModelTooLargeError): count_match = re.search(r"(\d+) parameters", raw) count = int(count_match.group(1)) if count_match else 0 @@ -464,6 +478,7 @@ def get_job(job_id: str): "bench": job["bench"], "error": job["error"], "artifact_id": job.get("artifact_id"), + "lineage": job.get("lineage"), "progress": job.get("progress"), } diff --git a/compilerlens/ingest/build.py b/compilerlens/ingest/build.py index f6f95ab..d9ef7ba 100644 --- a/compilerlens/ingest/build.py +++ b/compilerlens/ingest/build.py @@ -459,12 +459,17 @@ def add(kind: str, label: str, value: str, stage: Stage, detail: str, status: st return evidence, target -def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, analysis: dict | None = None) -> Artifact: +def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, + analysis: dict | None = None, lineage: str = 'auto') -> Artifact: """Build one workload's artifact. `root` overrides `workload.dump_root` -- used by Stage 5 to point at a fresh temp directory from a live compile while reusing the same registered stage/pass-track specs. + Reuse supplied capture analysis, or analyze the dumps using the same native pipeline. + Generated reports and lineage.json are written beside the dumps; original IR is retained. """ + if lineage not in ('auto', 'required', 'off'): + raise ValueError('Invalid lineage mode.') dump_root = root if root is not None else Path(workload.dump_root) repo_relative = str(workload.dump_root).rstrip("/") @@ -472,6 +477,10 @@ def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, analysis if not stages: raise SystemExit(f"no stages found under {dump_root}") + if analysis is None: + from compilerlens.lineage import analyze_files + analysis = analyze_files(dump_root, lineage) + source_sets = None if analysis is not None: from compilerlens.lineage import enrich_stages @@ -570,8 +579,20 @@ def main() -> None: parser.add_argument("--out-dir", type=Path, help="directory to write .json + index.json (--all mode)") parser.add_argument("--id", default=None, help="compilation id override (ad hoc mode; defaults to dir name)") parser.add_argument("--indent", type=int, default=None, help="pretty-print the JSON") + parser.add_argument("--lineage", choices=('auto', 'required', 'off'), default='auto', + help="Native analysis: auto permits fallback, required fails on missing/failed analysis, off skips it") args = parser.parse_args() + def build(workload, root=None): + try: + artifact = build_artifact(workload, root=root, lineage=args.lineage) + except RuntimeError as exc: + parser.exit(1, f"{workload.id}: {exc}\n") + for note in artifact.notes: + if note.startswith('Lineage:'): + print(f" {workload.id}: {note}", file=sys.stderr) + return artifact + if args.all: if not args.out_dir: raise SystemExit("--all requires --out-dir") @@ -583,7 +604,7 @@ def main() -> None: if not root.is_dir(): print(f" warning: {root} missing, skipping workload {workload_id}", file=sys.stderr) continue - artifact = build_artifact(workload) + artifact = build(workload) artifacts[workload_id] = artifact out_path = args.out_dir / f"{workload_id}.json" @@ -618,7 +639,7 @@ def main() -> None: f"to test its specs against this directory." ) - artifact = build_artifact(workload, root=args.dump_dir) + artifact = build(workload, root=args.dump_dir) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(artifact.to_json(indent=args.indent)) diff --git a/docs/CLI.md b/docs/CLI.md index 8d5a6eb..a0239e7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -142,6 +142,12 @@ returns recorded frames and joins them to the captured dispatch and Torch source ## Build/develop the native pass +For system prerequisites and the complete source installation, start with the +[README source steps](../README.md#installation-from-source). Miniconda installs the build tools, +LLVM SDK and Python dependencies in one user-local environment without sudo, then you build +our pass and generate viewer artifacts. The source environment replaces the separate venv. +The PyPI package provides the separate prebuilt installation path. + See the [LLVM component overview](../llvm/README.md) for the analysis flow and file layout. Configure a fresh `build/llvm/` directory after the source-directory rename; CMake caches contain absolute source paths and should not be moved from the previous build directory. @@ -165,7 +171,24 @@ annotations parse, invalid IR fails, object lookups work and normalized IR is un Use an LLVM 22 SDK matching `opt` for the tested plugin. The CMake project permits LLVM 23 for source-build development, which requires validation with that particular SDK. The plugin ABI must match its host. `COMPILERLENS_NATIVE=/path/to/compilerlens-native` overrides the -private executable; `COMPILERLENS_WORKSPACE=/path` selects server storage. +private executable; `COMPILERLENS_WORKSPACE=/path` selects server storage. The README's Conda +build uses `build/llvm-conda/`; set `COMPILERLENS_NATIVE` to that build's executable as shown +there. These generic commands also work with a separately provisioned compatible SDK. + +After building the analyzer, regenerate the source frontend's existing workloads with: + +```bash +cd frontend +npm run artifact -- --lineage required +``` + +The artifact builder invokes the same native analysis as CLI captures and writes `native/` +reports and `lineage.json` beside each workload's dumps. It preserves the original LLVM IR. +The default mode is `auto` (native analysis with fallback); `--lineage off` skips native +analysis. The command prints each workload's lineage status. Source Web Explore uses +`COMPILERLENS_NATIVE` from the API terminal; the README selects the Conda build explicitly. +Both source and installed Web Explore use `auto` automatically, falling back if native +analysis is unavailable or fails. No lineage selection is needed in the webpage. An optional [IREE source-build hook](../llvm/iree/README.md) brackets its actual optimization pipeline, including O0. Its C++ hook is tested; the pinned source patch is supplied. A full 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/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/llvm/README.md b/llvm/README.md index 97fe608..19a1027 100644 --- a/llvm/README.md +++ b/llvm/README.md @@ -41,11 +41,22 @@ can be missing or shared, and operand dependencies do not establish source owner ## 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. diff --git a/tests/test_explore_lineage.py b/tests/test_explore_lineage.py new file mode 100644 index 0000000..52f2106 --- /dev/null +++ b/tests/test_explore_lineage.py @@ -0,0 +1,76 @@ +"""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 + 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(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_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() From 490484ed61e46b4ea916edb2f182b5027982da94 Mon Sep 17 00:00:00 2001 From: Soumya Banerjee Date: Tue, 29 Sep 2026 18:42:44 +0530 Subject: [PATCH 09/14] Add experimental ViT and CLIP adapters with offline demos and verification --- README.md | 32 ++- compilerlens/backend/api/app.py | 17 +- compilerlens/backend/compiler/runner.py | 27 ++- compilerlens/cli.py | 3 +- compilerlens/examples/__init__.py | 9 + compilerlens/examples/tiny_clip.py | 33 +++ compilerlens/examples/tiny_vit.py | 22 ++ compilerlens/ingest/architecture.py | 2 + compilerlens/ingest/build.py | 6 + compilerlens/ingest/workloads/generated.py | 10 +- compilerlens/models/adapters.py | 118 ++++++++++ compilerlens/models/architecture.py | 2 + compilerlens/models/detect.py | 83 ++++++- compilerlens/models/hf_wrapper.py | 39 +++- compilerlens/models/verification.py | 47 ++++ compilerlens/services.py | 8 +- docs/CLI.md | 12 +- frontend/src/ArchitectureExplorerPage.tsx | 24 +- frontend/src/LandingPage.tsx | 10 +- frontend/src/api/artifact.ts | 10 + frontend/src/styles/architecture.css | 3 +- scripts/check_multimodal_viewer.mjs | 35 +++ scripts/compile_hf_model.py | 21 +- tests/test_model_adapters.py | 242 +++++++++++++++++++++ 24 files changed, 754 insertions(+), 61 deletions(-) create mode 100644 compilerlens/examples/tiny_clip.py create mode 100644 compilerlens/examples/tiny_vit.py create mode 100644 compilerlens/models/adapters.py create mode 100644 compilerlens/models/verification.py create mode 100644 scripts/check_multimodal_viewer.mjs create mode 100644 tests/test_model_adapters.py diff --git a/README.md b/README.md index a2d8ea1..5b3728c 100644 --- a/README.md +++ b/README.md @@ -540,15 +540,33 @@ the model. ## Supported Model Architectures -The automatic wrapper currently supports: +The CLI, Web Explore, Playground, and source scripts share the same model adapters: -- Encoder-only text models returning `last_hidden_state` -- Decoder-only text models returning `logits` -- Inputs shaped as `input_ids` plus a static 4D floating-point attention mask +| Family | Captured forward pass | +|---|---| +| Text encoders (BERT-like) | Token IDs + mask → hidden states | +| Causal text models (GPT-like) | Token IDs + mask → logits; no generation loop | +| ViT (experimental) | Image tensor → hidden states; classification head excluded | +| CLIP (experimental) | Image + text → embeddings and similarity logits | + +ViT/CLIP support is **unreleased**, validated on tiny local models—not yet on downloaded +checkpoints. It uses static, batch-one CPU/float32 workloads with synthetic inputs. Audio, +other multimodal families, encoder-decoder models, and real-media preprocessing remain future work. + +Try the offline demos from the repo root with your source environment active +(random weights; no downloads or extra dependencies): + +```bash +python -m compilerlens compile --example tiny_vit --out examples/tiny_vit +python -m compilerlens compile --example tiny_clip --out examples/tiny_clip +cd frontend +npm run artifact +``` -Vision, audio, multimodal, and encoder-decoder models need additional input wrappers and are -rejected instead of being compiled with an incorrect signature. Sequence length is fixed at -compile time; the website uses 16 tokens for a compact first run. +Refresh the [source webpage](#running-compilerlens-locally); use fresh output paths if these +directories exist. These demos are not in PyPI `0.1.1`. Native lineage remains automatic; +ViT/CLIP captures on host/generic CPU also compare compiled outputs with PyTorch and save +`verification.json`. See the [CLI guide](docs/CLI.md#capture-parameters) for capture details. ## Repository Compilation Script diff --git a/compilerlens/backend/api/app.py b/compilerlens/backend/api/app.py index 565db63..b18964d 100644 --- a/compilerlens/backend/api/app.py +++ b/compilerlens/backend/api/app.py @@ -332,7 +332,7 @@ def _friendly_explore_error(exc: Exception, model_id: str, phase: str) -> str: 'or choose Automatic or Off to compile without requiring native analysis.' ) - if isinstance(exc, ExploreModelTooLargeError): + if isinstance(exc, ExploreModelTooLargeError) or 'parameter interactive limit' in lower: count_match = re.search(r"(\d+) parameters", raw) count = int(count_match.group(1)) if count_match else 0 size = f" ({count / 1_000_000:.0f}M parameters)" if count else "" @@ -354,8 +354,8 @@ def _friendly_explore_error(exc: Exception, model_id: str, phase: str) -> str: return "CompilerLens could not reach Hugging Face. Check the server's network connection and try again." if "no vocab_size" in lower: return ( - f"'{model_id}' is not a supported text model. CompilerLens currently expects a model " - "that accepts token IDs, such as a BERT-like encoder or GPT-like decoder." + f"'{model_id}' has no supported input adapter. Try a text encoder/causal LM, " + "ViT image encoder, or CLIP image-text model. Other families are not supported yet." ) if "encoder-decoder" in lower or "decoder_input_ids" in lower: return ( @@ -394,7 +394,7 @@ def _run_compile(job_id: str, request: CompileRequest, flags: list) -> None: from compilerlens.models.hf_wrapper import wrap detected = detect(request.model_id, seq_len=request.seq_len) - module, example_inputs, model_info = wrap(detected) + module, example_inputs, model_info = wrap(detected, max_parameters=_MAX_EXPLORE_PARAMETERS) job["model_info"] = model_info work_dir = REPO_ROOT / "jobs" / job_id @@ -432,6 +432,15 @@ def _run_compile(job_id: str, request: CompileRequest, flags: list) -> None: return # Only the stages that were asked for -- this is what keeps a flag change ~1s. + if model_info.get('adapter') in ('vit', 'clip'): + from compilerlens.models.verification import verify_forward + # Playground flags can target a different CPU, which is unsafe to execute here. + cpu_flag = next((flag.split('=', 1)[1] for flag in flags + if flag.startswith('--iree-llvmcpu-target-cpu=')), 'host') + if cpu_flag in ('host', 'generic'): + job['verification'] = verify_forward(vmfb, module, example_inputs, + model_info['output_names'], work_dir / 'verification.json') + for stage in request.stages: path = work_dir / f"ir_{STAGE_INDEX[stage]}_{stage}.mlir" stage_result = subprocess.run( diff --git a/compilerlens/backend/compiler/runner.py b/compilerlens/backend/compiler/runner.py index b8aeefb..8e6f9a6 100644 --- a/compilerlens/backend/compiler/runner.py +++ b/compilerlens/backend/compiler/runner.py @@ -10,6 +10,7 @@ import re import shutil from compilerlens.toolchain import find_tool +from compilerlens.examples import EXAMPLES import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -195,7 +196,8 @@ def run( # One real checkpoint per stage `_dump_named_stages` actually compiles, plus the five # other phases below -- a true count of what `run()` does, not a guessed one, so a # caller can show honest progress instead of an animated bar with no real meaning. - total_steps = 5 + len(self.config.stages) + verify = model_info is not None and model_info.get('adapter') in ('vit', 'clip') + total_steps = 5 + len(self.config.stages) + int(verify) progress = {"done": 0} def report(label: str) -> None: @@ -210,6 +212,22 @@ def report(label: str) -> None: self._dump_named_stages(torch_input_path, mlir_dir, manifest, report) self._dump_llvm_intermediates(torch_input_path, output_dir, dumps_dir, manifest) report("Capturing LLVM intermediates") + if verify: + from compilerlens.models.verification import verify_forward + from compilerlens.storage import atomic_json + verification_path = output_dir / 'verification.json' + if manifest['errors']: + atomic_json(verification_path, {'status': 'skipped', 'reason': 'Compilation failed.'}) + elif self.config.target_backend != 'llvm-cpu' or self.config.target_cpu not in ('host', 'generic'): + atomic_json(verification_path, {'status': 'skipped', 'reason': 'Verification requires host or generic CPU.'}) + else: + try: + verify_forward(output_dir / f'{output_dir.name}_compiled_host.vmfb', module, + example_input_args, model_info['output_names'], verification_path) + except Exception as exc: + manifest['errors'].append({'stage': 'verification', 'stderr': str(exc)}) + manifest['files']['verification.json'] = str(verification_path) + report('Verifying compiled outputs') self._dump_full_pass_traces(torch_input_path, mlir_dir, passes_dir, manifest) report("Capturing per-pass traces") self._summarize_operators(mlir_dir, manifest) @@ -415,13 +433,6 @@ def _summarize_dispatches(self, output_dir: Path, manifest: dict) -> None: manifest["unique_dispatches"] = len(set(normalized_bodies)) -EXAMPLES = { - "matmul": "compilerlens.examples.matmul", - "linear_relu": "compilerlens.examples.linear_relu", - "mini_transformer": "compilerlens.examples.mini_transformer", -} - - def _load_example(name: str): import importlib diff --git a/compilerlens/cli.py b/compilerlens/cli.py index addd64c..ad7d489 100644 --- a/compilerlens/cli.py +++ b/compilerlens/cli.py @@ -11,6 +11,7 @@ from pathlib import Path from . import __version__ +from .examples import EXAMPLES from .storage import load_run @@ -34,7 +35,7 @@ def parser(): compile = sub.add_parser('compile', help='Compile and capture a model into a fresh run') compile.add_argument('model_id', nargs='?') inputs = compile.add_mutually_exclusive_group() - inputs.add_argument('--example', choices=('matmul', 'linear_relu', 'mini_transformer')) + inputs.add_argument('--example', choices=tuple(EXAMPLES)) inputs.add_argument('--python', help='Local file.py:factory returning (module, example_inputs)') compile.add_argument('--seq-len', type=positive, help='HF static sequence length (default: 16)') compile.add_argument('--revision', help='HF commit or reference; resolved commit is saved') diff --git a/compilerlens/examples/__init__.py b/compilerlens/examples/__init__.py index 79ec2f4..1c049cb 100644 --- a/compilerlens/examples/__init__.py +++ b/compilerlens/examples/__init__.py @@ -1 +1,10 @@ """Small offline compiler workloads.""" +"""Packaged workloads; importing the registry does not load PyTorch or models.""" + +EXAMPLES = { + 'matmul': 'compilerlens.examples.matmul', + 'linear_relu': 'compilerlens.examples.linear_relu', + 'mini_transformer': 'compilerlens.examples.mini_transformer', + 'tiny_vit': 'compilerlens.examples.tiny_vit', + 'tiny_clip': 'compilerlens.examples.tiny_clip', +} diff --git a/compilerlens/examples/tiny_clip.py b/compilerlens/examples/tiny_clip.py new file mode 100644 index 0000000..bed6e83 --- /dev/null +++ b/compilerlens/examples/tiny_clip.py @@ -0,0 +1,33 @@ +"""Offline image-text similarity demo: random weights, no meaningful predictions.""" +import torch +from transformers import CLIPConfig, CLIPModel + +from compilerlens.models.adapters import ADAPTERS, CLIPWrapper, clip_inputs + +PROFILE = {'image_size': [8, 8], 'num_channels': 3, 'vocab_size': 16, + 'bos_token_id': 1, 'eos_token_id': 15} +SEQ_LEN = 4 + + +def build_module(): + text = dict(vocab_size=16, hidden_size=8, intermediate_size=16, + num_hidden_layers=1, num_attention_heads=2, + max_position_embeddings=8, bos_token_id=1, eos_token_id=15, pad_token_id=0) + vision = dict(image_size=8, patch_size=4, num_channels=3, hidden_size=8, + intermediate_size=16, num_hidden_layers=1, num_attention_heads=2) + config = CLIPConfig(text_config=text, vision_config=vision, projection_dim=4) + config._attn_implementation = 'eager' + return CLIPWrapper(CLIPModel(config)).eval() + + +def example_inputs(): + return clip_inputs(PROFILE, SEQ_LEN) + + +def model_info(): + inputs = (torch.empty(1, SEQ_LEN, dtype=torch.int64, device='meta'), + torch.empty(1, 3, 8, 8, device='meta'), + torch.empty(1, 1, SEQ_LEN, SEQ_LEN, device='meta')) + return {'model_id': 'tiny_clip', 'model_type': 'clip', 'weights': 'random', + 'seq_len': SEQ_LEN, 'vocab_size': 16, 'causal': False, + **ADAPTERS['clip'].metadata(None, inputs)} diff --git a/compilerlens/examples/tiny_vit.py b/compilerlens/examples/tiny_vit.py new file mode 100644 index 0000000..6a895b4 --- /dev/null +++ b/compilerlens/examples/tiny_vit.py @@ -0,0 +1,22 @@ +"""Offline vision-encoder demo: random weights and synthetic image tensors.""" +import torch +from transformers import ViTConfig, ViTModel + +from compilerlens.models.adapters import ADAPTERS, ViTWrapper + + +def build_module(): + config = ViTConfig(image_size=8, patch_size=4, num_channels=3, hidden_size=8, + intermediate_size=16, num_hidden_layers=1, num_attention_heads=2) + config._attn_implementation = 'eager' + return ViTWrapper(ViTModel(config)).eval() + + +def example_inputs(): + return (torch.randn(1, 3, 8, 8),) + + +def model_info(): + return {'model_id': 'tiny_vit', 'model_type': 'vit', 'weights': 'random', + 'seq_len': None, 'causal': False, + **ADAPTERS['vit'].metadata(None, (torch.empty(1, 3, 8, 8, device='meta'),))} diff --git a/compilerlens/ingest/architecture.py b/compilerlens/ingest/architecture.py index 28c2a16..555e9ed 100644 --- a/compilerlens/ingest/architecture.py +++ b/compilerlens/ingest/architecture.py @@ -88,6 +88,8 @@ def _fallback(stages: list, model_info: dict) -> dict: "sequence_length": model_info.get("seq_len"), "hidden_size": None, "layer_count": None, "attention_heads": None, "intermediate_size": None, "vocab_size": model_info.get("vocab_size"), + **{key: model_info[key] for key in ('modalities', 'task', 'input_profile', 'output_names', 'weights') + if key in model_info}, }, "nodes": [root, *children], "edges": edges, } diff --git a/compilerlens/ingest/build.py b/compilerlens/ingest/build.py index d9ef7ba..6c3debe 100644 --- a/compilerlens/ingest/build.py +++ b/compilerlens/ingest/build.py @@ -512,6 +512,12 @@ def build_artifact(workload: WorkloadSpec, root: Path | None = None, *, "LLVM display views add ID comments; original dumps are retained.", ] notes.extend(stage_notes) + verification_path = dump_root / 'verification.json' + if verification_path.is_file(): + verification = json.loads(verification_path.read_text()) + notes.append(f"Compiled output verification: {verification['status']}. " + ( + 'Compared with PyTorch on one captured input sample; not a model-quality evaluation.' + if verification['status'] == 'passed' else verification.get('reason', verification.get('error', '')))) if analysis is not None: notes.append("Lineage: " + analysis["status"] + ". Native IDs are snapshot-local; assembly links use emitted .file/.loc associations. Source attribution is not instruction identity.") notes.extend(analysis["notes"]) diff --git a/compilerlens/ingest/workloads/generated.py b/compilerlens/ingest/workloads/generated.py index 0149130..78a2027 100644 --- a/compilerlens/ingest/workloads/generated.py +++ b/compilerlens/ingest/workloads/generated.py @@ -123,8 +123,12 @@ def spec_from_dump_dir(dump_root: Path, model_info: dict) -> WorkloadSpec: model_id = model_info.get("model_id", dump_root.name) params = model_info.get("param_count") param_text = f"{params / 1e6:.1f}M parameters" if params else "unknown size" - kind = "causal decoder" if model_info.get("causal") else "bidirectional encoder" - seq_len = model_info.get("seq_len") + task = model_info.get('task') + profile_text = f"; {task}" if task else '' + if model_info.get('input_profile', {}).get('kind') == 'synthetic': + profile_text += '; synthetic inputs' + if model_info.get('weights') == 'random': + profile_text += '; random weights (compiler demo)' stages: list[StageSpec] = [] @@ -200,7 +204,7 @@ def spec_from_dump_dir(dump_root: Path, model_info: dict) -> WorkloadSpec: return WorkloadSpec( id=dump_root.name, title=model_id, - description=f"{model_id} -- {param_text}; captured compiler stages.", + description=f"{model_id} -- {param_text}{profile_text}; captured compiler stages.", dump_root=str(dump_root), source_entry=model_id, stages=stages, diff --git a/compilerlens/models/adapters.py b/compilerlens/models/adapters.py new file mode 100644 index 0000000..1ee5f41 --- /dev/null +++ b/compilerlens/models/adapters.py @@ -0,0 +1,118 @@ +"""Model-family contracts for fixed-shape, CPU/float32 compiler workloads. + +These adapters compile tensor forward passes, not tokenization, image decoding, +classification postprocessing, or generation. No processor packages are required. +""" +from __future__ import annotations + +from dataclasses import dataclass + +import torch + + +class ViTWrapper(torch.nn.Module): + def __init__(self, hf_model): + super().__init__() + self.hf_model = hf_model + + def forward(self, pixel_values): + return self.hf_model(pixel_values=pixel_values).last_hidden_state + + +class CLIPWrapper(torch.nn.Module): + def __init__(self, hf_model): + super().__init__() + self.hf_model = hf_model + + def forward(self, input_ids, pixel_values, attention_mask): + output = self.hf_model(input_ids=input_ids, pixel_values=pixel_values, + attention_mask=attention_mask, return_loss=False) + return output.image_embeds, output.text_embeds, output.logits_per_image + + +def vision_inputs(config: dict) -> tuple: + return (torch.randn(1, config['num_channels'], *config['image_size']),) + + +def clip_inputs(config: dict, seq_len: int) -> tuple: + from .hf_wrapper import build_attention_mask + + # Include a real end-of-text marker; CLIP pools at EOS. Legacy CLIP configs + # with eos_token_id=2 instead pool at the largest token ID in the sequence. + eos = config['eos_token_id'] + pool_token = config['vocab_size'] - 1 if eos == 2 else eos + # Exclude EOS from the random interior to keep the selected position stable. + ids = torch.randint(0, config['vocab_size'] - 1, (1, seq_len)) + ids = ids + (ids >= pool_token).to(ids.dtype) + ids[0, 0] = config['bos_token_id'] + ids[0, -1] = pool_token + return ids, vision_inputs(config)[0], build_attention_mask(seq_len, causal=True) + + +@dataclass(frozen=True) +class ModelAdapter: + name: str + model_class: str + modalities: tuple[str, ...] + task: str + input_names: tuple[str, ...] + output_names: tuple[str, ...] + + def prepare(self, detected, hf_model): + if self.name == 'vit': + return ViTWrapper(hf_model), vision_inputs(detected.input_config) + if self.name == 'clip': + return CLIPWrapper(hf_model), clip_inputs(detected.input_config, detected.seq_len) + from .hf_wrapper import HFWrapper, build_example_inputs + return HFWrapper(hf_model, detected.output_attr), build_example_inputs(detected) + + def metadata(self, detected, inputs) -> dict: + names = self.output_names or (detected.output_attr,) + return { + 'adapter': self.name, + 'modalities': list(self.modalities), + 'task': self.task if self.name != 'text' else ( + 'causal-lm-forward' if detected.causal else 'text-encoder'), + 'input_profile': { + 'kind': 'synthetic', 'batch_size': 1, + 'preprocessing': 'outside compiled graph; synthetic tensors, not real media', + 'inputs': [{'name': name, 'shape': list(tensor.shape), + 'dtype': str(tensor.dtype).removeprefix('torch.')} + for name, tensor in zip(self.input_names, inputs)], + }, + 'output_names': list(names), + } + + +ADAPTERS = { + 'text': ModelAdapter('text', 'AutoModel', ('text',), 'text-encoder', + ('input_ids', 'attention_mask'), ()), + 'vit': ModelAdapter('vit', 'ViTModel', ('image',), 'image-encoder', + ('pixel_values',), ('last_hidden_state',)), + 'clip': ModelAdapter('clip', 'CLIPModel', ('image', 'text'), 'image-text-similarity', + ('input_ids', 'pixel_values', 'attention_mask'), + ('image_embeds', 'text_embeds', 'logits_per_image')), +} + + +def adapter_for(detected) -> ModelAdapter: + return ADAPTERS[detected.adapter] + + +def check_parameter_budget(detected, max_parameters: int | None) -> None: + """Reject oversized new model families before downloading their weights. + + Meta tensors retain shapes but allocate no parameter storage. This is a parameter + bound, not an estimate of peak compiler memory or disk usage. + """ + if max_parameters is None or detected.adapter == 'text': + return + import transformers + config_class = transformers.ViTConfig if detected.adapter == 'vit' else transformers.CLIPConfig + config = config_class.from_dict(detected.model_config) + config._attn_implementation = 'eager' + with torch.device('meta'): + model = getattr(transformers, adapter_for(detected).model_class)(config) + count = sum(parameter.numel() for parameter in model.parameters()) + if count > max_parameters: + raise ValueError(f'{count} parameters exceeds the {max_parameters} parameter interactive limit (before weight download)') diff --git a/compilerlens/models/architecture.py b/compilerlens/models/architecture.py index 474cdff..d2ad39a 100644 --- a/compilerlens/models/architecture.py +++ b/compilerlens/models/architecture.py @@ -153,6 +153,8 @@ def first(*names: str): "attention_heads": first("num_attention_heads", "n_head"), "intermediate_size": first("intermediate_size", "n_inner", "ffn_dim"), "vocab_size": first("vocab_size") or model_info.get("vocab_size"), + **{key: model_info[key] for key in ('modalities', 'task', 'input_profile', 'output_names', 'weights') + if key in model_info}, } diff --git a/compilerlens/models/detect.py b/compilerlens/models/detect.py index a32c353..fba4392 100644 --- a/compilerlens/models/detect.py +++ b/compilerlens/models/detect.py @@ -1,19 +1,14 @@ -"""Work out how to load and trace a HuggingFace model from nothing but its id. - -The point of this module is that adding a model costs no code: `compile_hf_model.py -prajjwal1/bert-tiny` should work without anyone registering anything. What makes that -awkward is that Hub configs are not uniform -- `prajjwal1/bert-tiny`, one of the models we -most want to demo, has neither `model_type` nor `architectures` in its config.json, and -`AutoConfig.from_pretrained` raises ValueError on it. So detection is a chain that falls -back to sniffing the raw config keys, and fails loudly rather than guessing when it cannot -tell. +"""Resolve a Hub revision and select a supported model-family input contract. + +Text detection retains legacy config-key inference for older BERT/GPT checkpoints. +Vision and multimodal models require explicit adapters, not guessed text signatures. """ from __future__ import annotations import json import re -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from huggingface_hub import HfApi, hf_hub_download @@ -55,6 +50,9 @@ class DetectedModel: detected_via: str # "autoconfig" | "config-keys" -- reported so the user sees which path ran offline: bool = False + adapter: str = 'text' + input_config: dict = field(default_factory=dict) + model_config: dict = field(default_factory=dict, repr=False) @property def slug(self) -> str: @@ -73,6 +71,10 @@ def load_model(self, *, dtype=None): from transformers import AutoConfig, AutoModel, AutoModelForCausalLM model_class = AutoModelForCausalLM if self.causal else AutoModel + if self.adapter != 'text': + import transformers + from .adapters import adapter_for + model_class = getattr(transformers, adapter_for(self).model_class) load_kwargs = {"revision": self.revision, "local_files_only": self.offline, "trust_remote_code": False} if dtype is not None: # Some checkpoints are stored as bfloat16. Turbine's current FX importer lifts @@ -80,6 +82,9 @@ def load_model(self, *, dtype=None): # directly in float32 avoids both that bridge failure and a temporary bf16->f32 # copy of the complete model after loading. load_kwargs["dtype"] = dtype + if self.adapter != 'text': + # Use exportable, explicit attention rather than backend-specific kernels. + load_kwargs['attn_implementation'] = 'eager' if self.detected_via == "autoconfig": return model_class.from_pretrained(self.model_id, **load_kwargs).eval() @@ -193,6 +198,8 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32, *, off Tries transformers' own AutoConfig first, then falls back to reading config.json directly. Raises UnsupportedModelError if neither identifies the model. """ + if seq_len <= 0: + raise ValueError('Sequence length must be positive.') resolved = _resolve_revision(model_id, revision, offline=offline) config = _raw_config(model_id, resolved, offline=offline) @@ -214,11 +221,27 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32, *, off # Fall through to the raw config -- AutoConfig is a nicety, not a requirement. pass + if model_type in ('vit', 'clip'): + profile = _vision_profile(config, model_type, seq_len) + return DetectedModel( + model_id=model_id, revision=resolved, causal=False, + vocab_size=profile.get('vocab_size', 0), seq_len=seq_len, + output_attr='last_hidden_state', model_type=model_type, + detected_via=detected_via, offline=offline, adapter=model_type, + input_config=profile, model_config=config, + ) + + if any(key in config for key in ('vision_config', 'audio_config', 'text_config')): + raise UnsupportedModelError( + f"'{model_id}' has a composite configuration without a supported input adapter. " + 'Only CLIP image-text models are included in the initial multimodal support.') + vocab_size = config.get("vocab_size") if not vocab_size: raise UnsupportedModelError( f"'{model_id}' config.json has no vocab_size, so we cannot build example input " - f"ids for it. Only text models taking input_ids are supported." + f"ids for it. Supported adapters: text encoders/causal LMs, ViT, and CLIP. " + f"Other vision, audio, and multimodal families are not supported yet." ) if (model_type or "") in _SEQ2SEQ_MODEL_TYPES or any( @@ -257,3 +280,41 @@ def detect(model_id: str, revision: str | None = None, seq_len: int = 32, *, off detected_via=detected_via, offline=offline, ) + + +def _vision_profile(config: dict, family: str, seq_len: int) -> dict: + """Validate static shapes before allocating tensors or downloading weights.""" + from transformers import CLIPConfig, ViTConfig + parsed = CLIPConfig.from_dict(config) if family == 'clip' else ViTConfig.from_dict(config) + vision = parsed.vision_config if family == 'clip' else parsed + size = vision.image_size + size = [size, size] if isinstance(size, int) else size + patch = vision.patch_size + patch = [patch, patch] if isinstance(patch, int) else patch + if (not isinstance(size, (tuple, list)) or not isinstance(patch, (tuple, list)) + or len(size) != 2 or len(patch) != 2 or any( + not isinstance(v, int) or v <= 0 for v in (*size, *patch)) + or any(s % p for s, p in zip(size, patch))): + raise UnsupportedModelError('Image size must be positive and divisible by the patch size.') + # Initial bounded profile: no arbitrary-resolution or positional interpolation. + if max(size) > 512 or (size[0] // patch[0]) * (size[1] // patch[1]) > 1024: + raise UnsupportedModelError('Initial vision profiles allow at most 512 pixels per side and 1024 patches.') + channels = vision.num_channels + if not isinstance(channels, int) or not 1 <= channels <= 4: + raise UnsupportedModelError('Initial vision profiles support 1–4 input channels.') + result = {'image_size': size, 'num_channels': channels} + if family == 'clip': + if not isinstance(vision.image_size, int) or not isinstance(vision.patch_size, int): + raise UnsupportedModelError('CLIP requires a square image with scalar image_size and patch_size.') + text = parsed.text_config + if not 2 <= seq_len <= text.max_position_embeddings: + raise UnsupportedModelError(f'CLIP sequence length must be between 2 and {text.max_position_embeddings}.') + if text.vocab_size < 3 or any(not isinstance(t, int) or not 0 <= t < text.vocab_size + for t in (text.bos_token_id, text.eos_token_id)): + raise UnsupportedModelError('CLIP requires valid BOS/EOS token IDs within its vocabulary.') + pool_token = text.vocab_size - 1 if text.eos_token_id == 2 else text.eos_token_id + if text.bos_token_id == pool_token: + raise UnsupportedModelError('CLIP synthetic profiles require distinct BOS and pooling token IDs.') + result.update(vocab_size=text.vocab_size, bos_token_id=text.bos_token_id, + eos_token_id=text.eos_token_id) + return result diff --git a/compilerlens/models/hf_wrapper.py b/compilerlens/models/hf_wrapper.py index a2bc765..1c78be4 100644 --- a/compilerlens/models/hf_wrapper.py +++ b/compilerlens/models/hf_wrapper.py @@ -51,7 +51,7 @@ def build_example_inputs(detected: DetectedModel) -> tuple[torch.Tensor, torch.T return input_ids, build_attention_mask(detected.seq_len, detected.causal) -def wrap(detected: DetectedModel) -> tuple[HFWrapper, tuple, dict]: +def wrap(detected: DetectedModel, *, max_parameters: int | None = None) -> tuple[torch.nn.Module, tuple, dict]: """Load, wrap, and build inputs for a detected model. Returns (module, example_inputs, model_info) where model_info goes into the manifest and @@ -60,29 +60,55 @@ def wrap(detected: DetectedModel) -> tuple[HFWrapper, tuple, dict]: # Export in float32 even when Hub weights are stored as bfloat16. Turbine currently # materializes parameter constants through NumPy, whose PyTorch bridge rejects bf16. # Loading directly in f32 avoids that importer failure (at the cost of larger memory use). + from .adapters import adapter_for, check_parameter_budget + check_parameter_budget(detected, max_parameters) hf_model = detected.load_model(dtype=torch.float32) - module = HFWrapper(hf_model, detected.output_attr) - example_inputs = build_example_inputs(detected) + adapter = adapter_for(detected) + module, example_inputs = adapter.prepare(detected, hf_model) model_info = { "model_id": detected.model_id, "revision": detected.revision, "model_type": detected.model_type, "causal": detected.causal, - "seq_len": detected.seq_len, - "vocab_size": detected.vocab_size, + "seq_len": detected.seq_len if detected.adapter != 'vit' else None, + "vocab_size": detected.vocab_size or None, "param_count": sum(p.numel() for p in hf_model.parameters()), "compile_dtype": "float32", "detected_via": detected.detected_via, + **adapter.metadata(detected, example_inputs), } return module, example_inputs, model_info -def wrapper_source(detected: DetectedModel) -> str: +def wrapper_source(detected: DetectedModel, seed: int = 0) -> str: """The equivalent standalone PyTorch program, for the artifact's source stage. The frontend shows this as the "PyTorch Source" stage, so it has to be something a reader could actually run -- not a description of what we did. """ + if detected.adapter != 'text': + from .adapters import adapter_for + adapter = adapter_for(detected) + wrapper = 'ViTWrapper' if detected.adapter == 'vit' else 'CLIPWrapper' + inputs = (f'vision_inputs({detected.input_config!r})' if detected.adapter == 'vit' + else f'clip_inputs({detected.input_config!r}, {detected.seq_len})') + return f'''import torch +import iree.turbine.aot as aot +from transformers import {adapter.model_class} +from compilerlens.models.adapters import {wrapper}, vision_inputs, clip_inputs + +# {adapter.task}: synthetic tensor inputs; preprocessing is outside this graph. +# Outputs: {', '.join(adapter.output_names)}. No generation or postprocessing. +torch.manual_seed({seed}) +model = {adapter.model_class}.from_pretrained( + {detected.model_id!r}, revision={detected.revision!r}, + local_files_only={detected.offline!r}, trust_remote_code=False, + dtype=torch.float32, attn_implementation="eager", +).eval() +module = {wrapper}(model).eval() +inputs = {inputs} +export_output = aot.export(module, *inputs) +''' model_class = "AutoModelForCausalLM" if detected.causal else "AutoModel" if detected.causal: mask_expr = ( @@ -98,6 +124,7 @@ def wrapper_source(detected: DetectedModel) -> str: # {detected.model_id} @ {detected.revision[:12]} # {detected.model_type}, {"causal decoder" if detected.causal else "bidirectional encoder"} +torch.manual_seed({seed}) model = {model_class}.from_pretrained( "{detected.model_id}", revision="{detected.revision}", diff --git a/compilerlens/models/verification.py b/compilerlens/models/verification.py new file mode 100644 index 0000000..8913c63 --- /dev/null +++ b/compilerlens/models/verification.py @@ -0,0 +1,47 @@ +"""Numerical smoke validation of a compiled forward pass on its capture inputs.""" +from pathlib import Path + +from compilerlens.storage import atomic_json + + +def verify_forward(vmfb: Path, module, inputs: tuple, output_names: list[str], + report_path: Path) -> dict: + """Write a report even on failure; never treat matching shapes as correctness. + + This checks one synthetic sample, not model quality or all possible inputs. + Call only for executables targeting the current host (or generic CPU). + """ + import numpy as np + import torch + import iree.runtime as runtime + + report = {'status': 'failed', 'rtol': 1e-4, 'atol': 1e-4, 'outputs': [], + 'scope': 'PyTorch vs compiled forward on the captured input tensors'} + try: + with torch.no_grad(): + expected = module(*inputs) + expected = expected if isinstance(expected, (tuple, list)) else (expected,) + vm = runtime.load_vm_flatbuffer(Path(vmfb).read_bytes(), driver='local-sync') + actual = vm.main(*(tensor.detach().cpu().numpy() for tensor in inputs)) + actual = actual if isinstance(actual, (tuple, list)) else (actual,) + if len(actual) != len(expected) or len(expected) != len(output_names): + raise ValueError('Output count differs from the adapter contract.') + for name, value, reference in zip(output_names, actual, expected): + value = value.to_host() + reference = reference.detach().cpu().numpy() + if value.shape != reference.shape or value.dtype != reference.dtype: + raise ValueError(f'{name}: output shape or dtype differs from PyTorch.') + if not np.isfinite(value).all() or not np.isfinite(reference).all(): + raise ValueError(f'{name}: non-finite output cannot be verified.') + error = float(np.max(np.abs(value - reference))) if value.size else 0.0 + report['outputs'].append({'name': name, 'shape': list(value.shape), + 'dtype': str(value.dtype), 'max_abs_error': error}) + np.testing.assert_allclose(value, reference, rtol=report['rtol'], atol=report['atol'], + err_msg=name, equal_nan=False) + report['status'] = 'passed' + except Exception as exc: + report['error'] = f'{type(exc).__name__}: {exc}' + raise RuntimeError(f'Compiled output verification failed: {exc}') from exc + finally: + atomic_json(report_path, report) + return report diff --git a/compilerlens/services.py b/compilerlens/services.py index d29c8dc..9b7de63 100644 --- a/compilerlens/services.py +++ b/compilerlens/services.py @@ -98,13 +98,15 @@ def report(label): from .models.detect import detect from .models.hf_wrapper import wrap, wrapper_source detected = detect(model_id, revision=revision, seq_len=seq_len or 16, offline=offline) - module, inputs, info = wrap(detected) - source = wrapper_source(detected) + module, inputs, info = wrap(detected, max_parameters=max_parameters) + source = wrapper_source(detected, seed=seed) elif example: if example not in EXAMPLES: raise ValueError(f'Unknown example: {example}. Choose {", ".join(EXAMPLES)}') workload = importlib.import_module(EXAMPLES[example]) module, inputs = workload.build_module(), workload.example_inputs() info = {'model_id': example, 'kind': 'example'} + if hasattr(workload, 'model_info'): + info.update(workload.model_info()) source = Path(workload.__file__).read_text() else: filename, separator, factory = python.rpartition(':') @@ -168,7 +170,7 @@ def import_dump(source, *, out=None, lineage='auto', progress=None) -> Path: atomic_json(root / 'run.json', state) try: # Copy only capture inputs, never arbitrary output or recursive run directories. - for name in ('mlir', 'llvm', 'passes', '_full', 'inputs', 'source.py', 'architecture.json', 'model_info.json', 'manifest.json'): + for name in ('mlir', 'llvm', 'passes', '_full', 'inputs', 'source.py', 'architecture.json', 'model_info.json', 'manifest.json', 'verification.json'): path = source / name if path.is_dir(): shutil.copytree(path, root / name) elif path.is_file(): shutil.copy2(path, root / name) diff --git a/docs/CLI.md b/docs/CLI.md index a0239e7..3a4dc3b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -39,8 +39,8 @@ LLVM 23 development snapshots; arbitrary future IR compatibility is not promised | Argument | Default | Meaning | |---|---|---| -| `MODEL_ID` / `--example NAME` / `--python FILE:FACTORY` | Choose one | HF text model, packaged example, or local Python factory | -| `--seq-len N` | 16, HF only | Static export sequence length | +| `MODEL_ID` / `--example NAME` / `--python FILE:FACTORY` | Choose one | HF model with a supported adapter, packaged example, or local Python factory | +| `--seq-len N` | 16, HF only | Static text length (including CLIP); not used by image-only ViT | | `--revision REF` | Resolve main | Record a concrete HF commit; offline requires a cached ref or explicit cached SHA | | `--offline` | false | Config and model loaders use cached resources only | | `--cpu NAME` | host | IREE LLVM CPU target | @@ -61,7 +61,13 @@ compilerlens import examples/matmul --out runs/imported --lineage required `build()` returns `(torch.nn.Module, tuple_of_example_tensors)`. The module is put in eval mode. This explicitly executes local code. HF-only arguments are rejected for local/examples inputs. -The packaged examples are `matmul`, `linear_relu`, and `mini_transformer`. +The published examples are `matmul`, `linear_relu`, and `mini_transformer`. +This source branch additionally includes **unreleased** `tiny_vit` and `tiny_clip` demos: +random weights, synthetic tensors, and no model downloads. See the +[model compatibility section](../README.md#supported-model-architectures) for their exact scope +and source-webpage commands. ViT/CLIP captures on host/generic CPU write `verification.json` +with a PyTorch-vs-compiled numerical check; failure prevents a successful capture. This checks +one input sample, not prediction quality. The existing text-model workflow is unchanged. A failed capture keeps `run.json` with its error and phase. Successful runs include: diff --git a/frontend/src/ArchitectureExplorerPage.tsx b/frontend/src/ArchitectureExplorerPage.tsx index 6d144bf..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)} @@ -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
-