forked from ccf-ai-infra/TileOPs-Metax
Compare commits
No commits in common. "summer-camp-2026" and "stats" have entirely different histories.
summer-cam
...
stats
|
|
@ -1,105 +0,0 @@
|
|||
#!/bin/bash
|
||||
# types.sh — Single source of truth for TileOPs commit/PR/issue type conventions.
|
||||
#
|
||||
# Usage:
|
||||
# source .claude/conventions/types.sh
|
||||
# # Now use $COMMIT_MSG_PATTERN, $BRANCH_NAME_PATTERN, TYPE_TO_LABEL, etc.
|
||||
#
|
||||
# This file is sourced by validation scripts, CI workflows, and skill docs.
|
||||
# Update HERE first, then all consumers pick up the change automatically.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commit / PR types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMMIT_PR_TYPES="Bench|BugFix|Chore|CI|Design|Doc|Enhancement|Feat|Fix|Maintain|Perf|Refactor|Style|Test"
|
||||
|
||||
# Full commit message / PR title regex
|
||||
# [Type] description or [Type][Scope] description
|
||||
COMMIT_MSG_PATTERN="^\[(${COMMIT_PR_TYPES})\](\[[a-zA-Z0-9_-]+\])? .+"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branch naming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRANCH_PREFIXES="bench|chore|design|doc|feat|fix|maintain|perf|refactor|style|test"
|
||||
BRANCH_NAME_PATTERN="^(${BRANCH_PREFIXES})/[a-z0-9._-]+/[a-z0-9._-]+$"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type → GitHub label mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
declare -A TYPE_TO_LABEL=(
|
||||
[Bench]=bench
|
||||
[BugFix]=fix
|
||||
[Chore]=chore
|
||||
[CI]=ci
|
||||
[Design]=design
|
||||
[Doc]=docs
|
||||
[Enhancement]=enhancement
|
||||
[Feat]=feature
|
||||
[Fix]=fix
|
||||
[Maintain]=maintain
|
||||
[Perf]=perf
|
||||
[Refactor]=refactor
|
||||
[Style]=style
|
||||
[Test]=test
|
||||
)
|
||||
|
||||
# All type-related labels (used for stale-label cleanup)
|
||||
ALL_TYPE_LABELS="bench bug chore ci design docs enhancement feature fix maintain perf refactor style test"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue types (ALL CAPS, used in issue titles)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ISSUE_TYPES="BENCHMARK|BUG|DESIGN|DOCS|FEAT|MAINTAIN|META|PERF|REFACTOR|STYLE|TEST"
|
||||
|
||||
# Issue type → GitHub label (for auto-label workflow)
|
||||
declare -A ISSUE_TYPE_TO_LABEL=(
|
||||
[BENCHMARK]=bench
|
||||
[BUG]=bug
|
||||
[DESIGN]=design
|
||||
[DOCS]=docs
|
||||
[FEAT]=feature
|
||||
[MAINTAIN]=maintain
|
||||
[META]=chore
|
||||
[PERF]=perf
|
||||
[REFACTOR]=refactor
|
||||
[STYLE]=style
|
||||
[TEST]=test
|
||||
)
|
||||
|
||||
# Issue type → commit/PR type prefix
|
||||
declare -A ISSUE_TO_COMMIT_TYPE=(
|
||||
[BENCHMARK]=Bench
|
||||
[BUG]=BugFix
|
||||
[DESIGN]=Design
|
||||
[DOCS]=Doc
|
||||
[FEAT]=Feat
|
||||
[MAINTAIN]=Maintain
|
||||
[META]=Chore
|
||||
[PERF]=Enhancement
|
||||
[REFACTOR]=Refactor
|
||||
[STYLE]=Style
|
||||
[TEST]=Test
|
||||
)
|
||||
|
||||
# Issue type → branch prefix
|
||||
declare -A ISSUE_TO_BRANCH_PREFIX=(
|
||||
[BENCHMARK]=bench
|
||||
[BUG]=fix
|
||||
[DESIGN]=design
|
||||
[DOCS]=doc
|
||||
[FEAT]=feat
|
||||
[MAINTAIN]=maintain
|
||||
[META]=chore
|
||||
[PERF]=perf
|
||||
[REFACTOR]=refactor
|
||||
[STYLE]=style
|
||||
[TEST]=test
|
||||
)
|
||||
|
||||
# Default for unrecognized issue types
|
||||
ISSUE_DEFAULT_COMMIT_TYPE="Fix"
|
||||
ISSUE_DEFAULT_BRANCH_PREFIX="fix"
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
## Boundary
|
||||
|
||||
- **OWNS**: `benchmarks/`
|
||||
- **MUST NOT WRITE**: `tileops/ops/`, `tileops/kernels/`, `tests/`, `workloads/`, `tileops/manifest/`
|
||||
- **MUST NOT** (oracle-leakage rule): import oracle/ref functions from `tests/` or `workloads/`. Reads of any other file are unrestricted.
|
||||
|
||||
→ [trust-model.md §Benchmark](../../docs/design/trust-model.md#benchmark) | [testing.md §Benchmarks](../../docs/design/testing.md#benchmarks)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
- Every benchmark records ≥1 non-`tileops` baseline. If the external baseline is conditional, add a local torch fallback.
|
||||
- Tag names: lowercase, hyphen-separated. Tags starting with `tileops` are TileOPs entries; everything else is a baseline.
|
||||
- `calculate_flops()` / `calculate_memory()` return `None` to omit the metric.
|
||||
- Benchmark shapes reflect real DNN workloads (LLaMA-family by default). Annotate shape constants with the model/scenario; never use arbitrary flat numbers (262K, 1M, 4M).
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
- `docs/design/*.md` records top-level design decisions only — no file enumerations, line counts, or other implementation snapshots.
|
||||
- When a reviewer asks to add implementation detail (e.g. "list the current files"), push back unless the design itself changed. Test: would this wording survive the next 5 implementations?
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
## Boundary
|
||||
|
||||
- **OWNS**: `tileops/manifest/`
|
||||
- **MUST NOT WRITE**: `tileops/ops/`, `tileops/kernels/`, `tests/`, `benchmarks/`
|
||||
- Manifest changes require human review in a separate PR.
|
||||
|
||||
→ [trust-model.md §Manifest](../../docs/design/trust-model.md#manifest)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
- Manifest key must equal the Op `cls.__name__` exactly. Class-naming convention: see [ops-design.md](ops-design.md).
|
||||
|
||||
- `ref_api` (required): the external API the signature mirrors (e.g. `torch.nn.functional.rms_norm`); `"none"` if none. Validator enforces presence + string type only; semantics not checked.
|
||||
|
||||
- `inputs`, `outputs`, `params` are ordered dicts — key order is signature position. Don't reorder.
|
||||
|
||||
- Op signatures must match PyTorch's public API (names, set, semantics); include every supported parameter even if the kernel only honors the default. Default to `__init__` kwargs (lifetime-fixed); use `forward()` only when the reference API requires it or the value is per-batch — justify in the introducing issue.
|
||||
|
||||
- `dtype` syntax: `|` for alternatives. `same_as(ref)` is dtype-only identity (matches `ref` at runtime, no extra axis in `dtype_combos`, never used for shape).
|
||||
|
||||
- `dtype_combos` only when the supported set is a strict subset of the Cartesian product. Omit when all combinations are valid.
|
||||
|
||||
- Output shapes are fully specified by `shape` and/or `shape_rules`. `shape` present → fixed rank, names become roofline variables; `shape` absent on inputs → arbitrary rank, use `params` + `shape_rules`. Shared dim names across tensors → sizes must match.
|
||||
|
||||
- `shape_rules` are Python expressions describing shape relationships. For reduction-dim validation, use the canonical predicates / extractors in `tileops.manifest.shape_rules` (callable by bare name from any rule body); never silently wrap out-of-range indices with `% x.ndim`. Inline string expressions are a transitional fallback only.
|
||||
|
||||
- **Reduction `dim` authoring contract.** When `dim` accepts an integer or a sequence (`list[int]` / `tuple[int, ...]`), declare three `shape_rules` in this order:
|
||||
|
||||
1. **Range validity.** Every axis in `[-x.ndim, x.ndim)`. For ops accepting `None`: `"dim is None or all(-x.ndim <= d < x.ndim for d in ([dim] if isinstance(dim, int) else dim))"`. Drop the `dim is None or` prefix when the op does not accept `None`.
|
||||
1. **Normalize negatives.** Downstream rules apply `% x.ndim` only after step 1, producing the canonical axis set `{d % x.ndim for d in dim}`.
|
||||
1. **Uniqueness (sequence only).** `"isinstance(dim, (int, type(None))) or len({d % x.ndim for d in dim}) == len(dim)"`.
|
||||
|
||||
Empty-sequence semantics is per-op:
|
||||
|
||||
- Ops accepting `dim=None` (`sum`, `mean`, `amax`, `amin`, `var`, `std`, `var_mean`, `all`, `any`, `count_nonzero`, `linalg.vector_norm` variants): empty sequence ≡ full reduction; formulas use `set(range(x.ndim))` as fallback.
|
||||
- Ops without `dim=None` (e.g. `logsumexp`): empty sequence is invalid; declare `"isinstance(dim, int) or len(dim) > 0"`.
|
||||
|
||||
- Roofline `vars` maps variable names to Python expressions over tensor shapes and params. Required for arbitrary-rank ops.
|
||||
|
||||
- `status` is required: `implemented` or `spec-only`.
|
||||
|
||||
- `torch_compile_fullgraph`: literal `true` only; omit for no promise; invalid on `spec-only`. Declare only ops with a registered cold `fullgraph=True` compile test. Semantics: [manifest.md](../../docs/design/manifest.md#torch_compile_fullgraph).
|
||||
|
||||
- No `Optional[Tensor]` in manifest. Conditional inputs split into variant entries linked by `variant_of` (single-level, no chaining). Variants share `source.kernel` and `source.op`; each carries its own `signature`, `workloads`, `roofline`.
|
||||
|
||||
- Tensor layout defaults to contiguous row-major. Non-default needs an explicit `layout` field; `shape` dim names reflect memory order.
|
||||
|
||||
- `source.kernel_map` is the Op→Kernel dispatch registration table (`dispatch_key: KernelClassName`). It declares what an Op uses, not how dispatch picks.
|
||||
|
||||
- Never modify manifest to match non-conforming code. Code drift → `status: spec-only` and fix code in a follow-up PR. Never remove `params`, roofline `vars`, or `shape_rules` to silence validator errors.
|
||||
|
||||
- `ref_api` is the spec oracle for the manifest signature, not an Op-layer dispatch target at forward time.
|
||||
|
||||
- **Manifest comment policy.** Comments may carry technical content the DSL can't express (schema clarifications, edge cases, conventions, file headers); they MUST NOT carry process metadata bound to a specific issue, PR, commit, or round. Keep only if meaningful after every issue/PR is renumbered; otherwise move to commit message, PR description, or follow-up issue.
|
||||
|
||||
Discovery scan: `grep -rnE '#[0-9]{3,}|[Ff]ollow.?up|AC-[0-9]+' tileops/manifest/*.yaml`
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
- Validator failures mean the op, manifest, or benchmark is wrong — **not** the validator. Do not edit validator code, downgrade errors to warnings, add skip paths, or special-case a new op to get CI green.
|
||||
- Manifest-key match is direct string equality on `cls.__name__`. Do not add normalization, casing fallbacks, or alias paths.
|
||||
- Validator-related infrastructure: `scripts/validate_manifest.py`, `tests/test_validate_manifest.py`, `docs/design/manifest.md`. Only modify these when the PR is explicitly about manifest schema, trust-model policy, or validator infrastructure — and document the change in the PR body.
|
||||
- If a new op cannot satisfy the current validator: stop and surface the mismatch. Do not "fix" it in the validator.
|
||||
- Full spec: [docs/design/manifest.md](../../docs/design/manifest.md) (Manifest Validation section).
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
## Boundary
|
||||
|
||||
- **OWNS**: `tileops/ops/`, `tileops/kernels/`
|
||||
- **MUST NOT WRITE**: `tests/`, `benchmarks/`, `workloads/`, `tileops/manifest/`
|
||||
|
||||
→ [trust-model.md §Implementation](../../docs/design/trust-model.md#implementation) | [ops-design.md](../../docs/design/ops-design.md)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
- Class names: PascalCase `{Name}{Direction}Op` (Op layer) or `{Name}{Direction}Kernel` (Kernel layer); direction suffix mandatory. Manifest author chooses `{Name}`. Builder functions stay snake_case.
|
||||
|
||||
- `kernel_map` is the Op→Kernel dispatch registration table: snake_case dispatch keys (decoupled from class names) → Kernel class names. Manifest declares it; agents implement the listed Kernels. See [ops-design-reference.md § Kernel Dispatch](../../docs/design/ops-design-reference.md#kernel-dispatch-kernel_map).
|
||||
|
||||
- Op `__init__` is keyword-only (`def __init__(self, *, ...)`). Parameter names come from the manifest: `shape` dim names (fixed-rank), `static_dims` keys (arbitrary-rank), `params` keys. Only manifest-declared information belongs in `__init__`.
|
||||
|
||||
- Arbitrary-rank ops declare construction-time values via manifest `static_dims`. Each entry is a single-axis reference `<tensor>.shape[<const_or_param>]`; other dims come from tensors at forward time. See [manifest.md R20](../../docs/design/manifest.md).
|
||||
|
||||
- Update `docs/design/ops-design.md` whenever you add/modify an intermediate base class, change a kernel-dispatch pattern, or introduce a new class-variable protocol.
|
||||
|
||||
- A new op family inheriting `Op` directly: first check whether an existing family's `forward()` flow already fits before creating a new base class. Record the decision in the PR.
|
||||
|
||||
- Per-op workarounds MUST NOT be promoted to a base-class shared mechanism (mixin, class attribute, shared method, opt-out flag) within the same op-family migration PR — even when multiple ops share the workaround. Promote only via a separate design PR that shows the mechanism is a genuine family invariant (would belong in the base even if no op had taken a shortcut), not a shared shortcut.
|
||||
|
||||
- PyTorch fallback at forward time is permitted only when TileLang cannot express the operation at the required shape AND no closed-form replacement exists in tensor primitives; document the call site with the blocking limitation and a tracking issue. Helper conveniences (`x.float().mean(...)` for clarity) are out of scope — the rule targets full-operator delegation.
|
||||
|
||||
- Inline roofline state contract: for every `signature.inputs` / `signature.params` name **referenced** by the op's manifest `roofline` expressions, the op exposes it on `self`. Inputs: `self.<input>` with `.shape` and `.ndim`, OR `self.<input>_shape` as a shape tuple/list. Params: `self.<param>`. Unreferenced names need not be exposed. See [docs/design/roofline.md §4.4.3](../../docs/design/roofline.md).
|
||||
|
||||
- Dynamo-traced `forward` MUST NOT construct a `Kernel` or enter a TileLang builder; call-time kernel resolution goes through the compile dispatch boundary. See [ops-design.md](../../docs/design/ops-design.md#compile-dispatch-boundary).
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
## Boundary
|
||||
|
||||
- **OWNS**: `tests/`, `workloads/` (test stage creates workload definitions first)
|
||||
- **MUST NOT WRITE**: `tileops/ops/`, `tileops/kernels/`, `benchmarks/`, `tileops/manifest/`
|
||||
|
||||
→ [trust-model.md §Test](../../docs/design/trust-model.md#test) | [testing.md §Tests](../../docs/design/testing.md#tests)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
- Every test case traces to a specific code path, dtype dispatch, or regression. No cases for combinatorial confidence.
|
||||
- Test all supported dtypes. Don't cross dtype and shape coverage unless the combination triggers a distinct code path.
|
||||
- Don't generate fixtures from `tileops/manifest/` workloads. Test parameters are a curated correctness subset.
|
||||
- Before committing: drop scaffolding tests that guarded intermediate implementation steps and don't guard any final code path.
|
||||
- Run `scripts/test_node_delta.py` on PRs touching test files. Growth on existing files → include the script output + a one-line justification in the PR body. New test files only → no delta report.
|
||||
- Binary-op tests cover broadcast semantics: bias-add `(B,S,D)+(1,1,D)`, row `(B,S,D)+(B,S,1)`, scalar `(M,N)+(1,1)`. Applies to arithmetic, comparison, logical, bitwise.
|
||||
- Skill development tests stay local — never commit anything under `.claude/skills/`.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
Run before approving any PR. Apply each item if its scope matches the diff. If any applicable check fails, REQUEST_CHANGES until the developer pushes a triage commit.
|
||||
|
||||
## Tests
|
||||
|
||||
- [ ] **Per-case verdict.** Triage every added/modified test case as `keep` / `shrink` / `delete`. Inline only on blockers; clean test PRs stay clean (`criteria.md §3`).
|
||||
|
||||
| Verdict | When | Inline? |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------- | ----------- |
|
||||
| `keep — guards <path/dtype>` | distinct code path or dtype (per `docs/design/testing.md §Test case policy`) | no |
|
||||
| `shrink — fold to <axis>` | Cartesian expansion; fold to "boundary + one representative interior point" | **blocker** |
|
||||
| `delete — duplicate of <node>` | same-failure-mode duplicate of a kept case | **blocker** |
|
||||
|
||||
Cases the reviewer cannot classify with confidence count as untriaged → **blocker inline** asking the developer for rationale. Every blocker must be resolved (shrink / delete, or downgrade to `keep` with rationale) before APPROVE.
|
||||
|
||||
- [ ] **Numerical floor.** Run `python scripts/test_node_delta.py --base upstream/main` (prereq: `upstream` points to tile-ai/TileOPs and is fresh — `git fetch upstream` first). REQUEST_CHANGES with the full node-ID list if existing-file growth > 25% AND any case carries an unresolved `shrink`/`delete` blocker or is untriaged. Absence of an inline is not itself a blocker — silent `keep` is the default.
|
||||
|
||||
- [ ] **Critical-path floor.** Never remove the last test on an output-distinguishing input: tile boundary, vectorization alignment, degenerate dimension (size = 1), or a dispatch branch carrying observable behavior. Tests with no output-distinguishing input are removable.
|
||||
|
||||
- [ ] **No AC defense.** Reject "AC-N required this matrix" — AC text does not bind the merged suite.
|
||||
|
||||
## Authoring discipline
|
||||
|
||||
- [ ] **PR body.** Conforms to `.foundry/mold/pr-body-template.md`; records final state only — what the PR does (Summary, scoped to the merged diff) + verification facts (test plan, pre-commit, structural readiness, test node delta). Strip dev-process narration: per-round fix history, tally IDs (`T001–T0NN`), "Driven by review iteration", reviewer-by-reviewer changelogs, abandoned approaches. Those belong in commit history / review threads. REQUEST_CHANGES if found.
|
||||
|
||||
- [ ] **Replies.** Outcome only — `Done in <sha>.`, `Won't-fix: <one-line reason>.` No commit-by-commit narration, "what I tried", thread/tally IDs, root-cause essays, or design restatements. Process detail ages out and clutters the thread. REQUEST_CHANGES asking the developer to edit the comment to a one-liner if found.
|
||||
|
||||
## Review process
|
||||
|
||||
- [ ] **Batch-once.** If only cleanup-class issues (keep / shrink / delete / rename / dedupe) remain with no correctness blockers, surface every such item from the full diff in this single pass. Don't defer — either include now or demote to advisory (no longer gates APPROVE).
|
||||
|
||||
- [ ] **Re-run on triage.** Re-run every applicable check above on the developer's triage commit before approving.
|
||||
|
||||
## Scope-specific
|
||||
|
||||
- [ ] **Skill edits (`.claude/skills/**`).** Require a tightening pass before APPROVE — condense wording without changing what the skill instructs. Verify: semantics preserved, every step has exactly one valid execution path, no example included unless load-bearing, retained examples reference durable concepts rather than implementation details that age out.
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
For `[Bench]` PRs. Benchmarks live downstream of every other layer and must never silently substitute for tests or for the manifest.
|
||||
|
||||
Load `.claude/domain-rules/benchmark.md` before reviewing.
|
||||
|
||||
Two non-negotiable principles cut across every event:
|
||||
|
||||
- **Independent baselines.** Every benchmark must record at least one non-`"tileops"` baseline (PyTorch, vendor, third-party kernel). A benchmark that only times TileOps against TileOps is a self-comparison, not a benchmark — reject it.
|
||||
- **No correctness assertions.** Benchmarks measure performance; they do not gate behavior. `assert torch.allclose(...)` and equivalents belong in `tests/`. A correctness check inside `benchmarks/` is a trust-layer violation regardless of how convenient it is.
|
||||
|
||||
#### Checklist
|
||||
|
||||
- [ ] **Boundary respected.** Diff stays inside `benchmarks/`. No edits to `tileops/ops/`, `tileops/kernels/`, `tests/`, `workloads/`, or `tileops/manifest/` (`.claude/domain-rules/benchmark.md §Boundary`).
|
||||
- [ ] **Independent baseline present.** Each new/edited benchmark records ≥1 non-`"tileops"` baseline. If the external baseline is conditional, a local torch fallback is registered.
|
||||
- [ ] **No correctness gating.** No `assert`, `torch.allclose`, or equivalent inside `benchmarks/`. Numeric mismatches surface through report columns, not exceptions.
|
||||
- [ ] **Realistic shapes.** Shape constants reflect real DNN workloads (LLaMA-family or equivalent), annotated with the model/scenario they represent. Arbitrary flat numbers (e.g., 262K, 1M, 4M) are rejected.
|
||||
- [ ] **Tag and FLOPs/memory hygiene.** Tags are lowercase hyphen-separated; `"tileops"`-prefixed tags are TileOps entries, all others are baselines. `calculate_flops()` / `calculate_memory()` return numeric or `None` consistently.
|
||||
- [ ] **`record()` arg style consistent.** Within one file, `BenchmarkReport.record()` uses Op object (preferred) or string name uniformly — no mid-file flips.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
For `[Doc]` and `[Design]`.
|
||||
|
||||
A flag in this domain names one of: a contradiction (cite file:line on both sides), a missing follow-up reference, a scope violation (quote the offending line), or a re-introduced removal (cite the prior commit).
|
||||
|
||||
#### Design docs (`docs/design/*.md`)
|
||||
|
||||
TileOps is a design-first project. Design docs guide agent development; tight scope keeps agents on top-level decisions instead of mechanics. Also load `.claude/domain-rules/design-docs.md`.
|
||||
|
||||
- [ ] **Top-level only.** Content is a target convention, module boundary, or contract. Reject added codebase mechanics, file enumerations, or implementation snapshots.
|
||||
- [ ] **No contradictions.** Cross-check against neighboring design docs and `tileops/manifest/`. Flag conflicting MUST / SHOULD with file:line on both sides.
|
||||
- [ ] **Concise.** Flag content that does not constrain the decision — history narration, illustrative examples, rationale that doesn't explain a choice.
|
||||
- [ ] **Drift-free.** If the doc states a target the code or manifest doesn't satisfy, the PR includes the change or links a follow-up issue.
|
||||
|
||||
#### Other docs
|
||||
|
||||
Covers READMEs, `CLAUDE.md` family, agent-facing skill docs, and source comments. Lighter bar — only consistency and drift matter.
|
||||
|
||||
- [ ] **No contradictions** with current code, manifest, or design docs. Cite file:line on both sides.
|
||||
- [ ] **Drift-free.** Implied code or manifest change is included in the PR or linked as a follow-up issue.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
For `[Feat]` and `[Enhancement]`. For op/kernel PRs the structural axis is governed by `docs/design/ops-design.md` and `docs/design/ops-design-reference.md`; this checklist covers cross-cutting axes only.
|
||||
|
||||
#### Checklist
|
||||
|
||||
- [ ] Op/kernel public interface matches its `tileops/manifest/` entry. No entry → wrong PR order
|
||||
- [ ] Manifest edits, if any, stay within the carve-out whitelist (`.claude/rules/manifest-trust-model.md`)
|
||||
- [ ] If feature is an op, spot-check the diff against at least one of the structural checks below; flag any divergence (full rules in `docs/design/ops-design-reference.md`):
|
||||
- Class name ≡ manifest entry key
|
||||
- `__init__` kwargs derivable from manifest (`static_dims` / `dtype` / `signature.params`)
|
||||
- `default_kernel_map` matches `source.kernel_map` verbatim
|
||||
- `forward` validation order: `_validate_dtypes` → shape rules → `static_dims` commitment → kernel call
|
||||
- `_validate_dtypes` matches manifest `dtype` / `dtype_combos`
|
||||
- `eval_roofline` is a plain-Python body (no class-level expression strings, no `ast.parse`)
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
For `[Maintain]`, `[Refactor][Manifest]`, and any PR that flips a manifest entry's `status`. The manifest is the authoritative spec for op interfaces — every check below exists to keep that authority real.
|
||||
|
||||
Load `.claude/domain-rules/manifest-spec.md` before reviewing.
|
||||
|
||||
Two non-negotiable principles cut across every event:
|
||||
|
||||
- **Reference semantic alignment.** Any change to an op's spec (signature, shape rules, dtype combos, roofline vars) must map back to an authoritative reference — PyTorch public API for `torch.nn.*` / `torch.nn.functional.*` names; the paper or vendor docs otherwise. Reverse-engineering from current TileOps code is forbidden — spec is upstream of code.
|
||||
- **`status` field truthfulness.** `status: implemented` is a hard claim that code conforms to the entry. The reviewer's job is to *disprove* it, not to take it on faith. Status flips that don't reflect actual conformance corrupt the trust model.
|
||||
|
||||
#### Add-manifest (new entry)
|
||||
|
||||
PRs from the `add-manifest` skill, or any PR that adds a previously-absent op entry.
|
||||
|
||||
- [ ] **Reference cited and authoritative.** Entry's `source` field points to PyTorch / paper / vendor docs. Reviewer can derive shape and dtype rules from that link alone.
|
||||
- [ ] **Signature matches reference exactly.** For `torch.nn.*` / `torch.nn.functional.*` names: parameter names, order, and defaults all match PyTorch's public API. No invented parameters.
|
||||
- [ ] **Required fields present.** `signature`, `shape_rules`, `dtype_combos`, `roofline`; `kernel_map` and `static_dims` where the family requires them.
|
||||
- [ ] **Lands as `spec-only`.** New entries never land as `implemented`, regardless of any existing code claiming to be ready.
|
||||
- [ ] **Validator green.** `scripts/validate_manifest.py` passes with no checks disabled.
|
||||
- [ ] **No code change.** Diff does not modify `tileops/ops/` or `tileops/kernels/`.
|
||||
|
||||
#### Fix-manifest (patch existing entry)
|
||||
|
||||
PRs from the `fix-manifest` skill — patches one missing structural field (`kernel_map`, `static_dims`) on an existing entry.
|
||||
|
||||
- [ ] **Scope is one structural field.** Diff modifies exactly one missing field. Edits to `signature`, `shape_rules`, `dtype_combos`, or `roofline` belong to `add-manifest`, not `fix-manifest` — reject and split.
|
||||
- [ ] **Reference still aligns.** Other reference-derivable fields (`signature`, `shape_rules`, `dtype_combos`) on the same entry have not silently drifted from the source URL. Spot-check at least one.
|
||||
- [ ] **Validator green.**
|
||||
- [ ] **No code change.** Diff does not modify `tileops/ops/` or `tileops/kernels/`.
|
||||
|
||||
#### Status flip (`spec-only` ↔ `implemented`)
|
||||
|
||||
Often bundled with a `[Refactor][Ops]` op-migration PR.
|
||||
|
||||
- [ ] **Conformance verified, not asserted.** Reviewer diffs the op's `__init__` and `forward` against the manifest entry field-by-field — names, types, defaults, shapes.
|
||||
- [ ] **Spec tests actually run.** No `pytest.skip`, `xfail`, or weakened assertion left from the `spec-only` era. Grep the test file before approving.
|
||||
- [ ] **`FIXME(staged-rollout)` markers tied to this op removed.**
|
||||
- [ ] **Flip back to `spec-only`** is legitimate only when implementation is removed or known-broken. Challenge any other rationale.
|
||||
- [ ] **Pure flip.** This PR changes `status` only — no rewrite of `signature` / `shape_rules` / `roofline`. If the entry needs spec edits to match implementation, that is reverse-engineering from code; reject and require a separate `add-manifest`- or `fix-manifest`-style PR.
|
||||
- [ ] **`source.kernel_map` present.** `status: implemented` requires the Op→Kernel dispatch map. The validator currently emits a *warning* for missing `kernel_map`; the human reviewer escalates that to a blocker on a flipped entry.
|
||||
- [ ] **`workloads` non-empty and tensor-input shapes complete.** `status: implemented` requires at least 2 workloads (`test_every_op_has_at_least_two_workloads`). Each row must declare a shape for *every* tensor input in `signature.inputs` — `input_shape` alone is not enough when the op has additional tensor inputs (e.g. `mask_shape`, `value_shape: []`, `min_shape`, `max_shape`). Empty `workloads: []` or a row missing any required tensor-input shape on a flipped entry is a blocker — copy from the sibling variant or derive from the op's typical usage shapes.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
Common rules for every checklist in this folder. Load before any title-specific checklist.
|
||||
|
||||
## Load order
|
||||
|
||||
1. `docs/design/trust-model.md` — stage contracts (`manifest → test → implementation → benchmark`).
|
||||
1. The title-specific checklist.
|
||||
|
||||
## Review lens
|
||||
|
||||
Apply trust-model rules as a review lens: surface cross-layer diffs as comments with a concrete citation; the author replies with rationale; the reviewer judges on content. Provenance labels (`automated` / `needs-review` / `nightshift`) record origin; review criteria apply uniformly.
|
||||
|
||||
## Cross-layer review criteria
|
||||
|
||||
| Criterion | Pass | Fail |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Oracle origin | `ref_program` resolves to PyTorch / NumPy / closed-form / IEEE-754 | Agent-fabricated literal as expected value |
|
||||
| Coverage set | Unchanged or strictly expanded; deletions target code paths the same diff removes | dtype / shape / sign cells removed with the code path still present |
|
||||
| New-path coverage | A test reaches each diff-added code path on an input that makes its output differ from the alternative. Aliases (paths with no output-distinguishing input) are exempt. | A diff-added path's output-distinguishing input has no test |
|
||||
|
||||
Cite the failing criterion by name.
|
||||
|
||||
## Comment quality
|
||||
|
||||
| Rule | Required form |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Concrete pointer | `file:line`, entry path, field name, parametrize axis, ref URL, test name, or the offending diff line |
|
||||
| Decidable claim | A concrete claim about that pointer. Hedged forms ("looks reasonable", "may want to verify", "consider revising", "could be clearer") fail this rule |
|
||||
| Suggestion scope | Suggestions stay within fixes for flagged items |
|
||||
|
||||
## Scope
|
||||
|
||||
Checklists are the floor. Add PR-specific checks when the diff warrants them.
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
For `[Refactor]`.
|
||||
|
||||
#### Checklist
|
||||
|
||||
- [ ] Any `xfail` / `skip` / loosened assertion has a `FIXME(staged-rollout)` block per `.claude/rules/code-style.md`
|
||||
- [ ] If the op has a manifest entry, signature still matches it
|
||||
- [ ] **Trust-layer scope.** Refactor stays within one trust layer per `docs/design/trust-model.md` (manifest / test (incl. `workloads/`) / implementation / benchmark). Cross-layer touches are judged by `pre-review.md` criteria (oracle origin, coverage set, new-path coverage).
|
||||
|
||||
#### Sub-types
|
||||
|
||||
- `[Refactor][Manifest]` → load `manifest.md` instead. `tileops/ops/` and `tileops/kernels/` must NOT change in the same PR.
|
||||
- `[Refactor][Ops]` / `[Refactor][<Family>]` that flips `status` → also load `manifest.md`.
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
For PRs that add or modify files under `tests/`. Single focus: justify the test node growth declared in the PR body's `## Test node delta` section. Without this guard, parametrize stacks expand into performance sweeps and dilute every test run.
|
||||
|
||||
Load `.claude/domain-rules/testing-budget.md` and `docs/design/testing.md §Test case policy` before reviewing.
|
||||
|
||||
**Burden of proof flips above threshold.** If growth crosses the threshold below, the author justifies; reviewer silence is not approval.
|
||||
|
||||
#### Checklist
|
||||
|
||||
- [ ] **Trigger.** Compute `delta_pct = (HEAD − Base) / Base × 100` from the PR body's `## Test node delta`. If `delta_pct > 10%`, every check below is required; otherwise this file is informational.
|
||||
- [ ] **Per-case purpose stated.** Each new case (or each new parametrize cell) serves exactly one of: dtype correctness / kernel-branch shape coverage / feature coverage / regression — per `docs/design/testing.md §Test case policy`. The PR body justification names which, with file:line.
|
||||
- [ ] **No Cartesian-product expansion.** Reject parametrize stacks whose growth is the product of two or more axes' cardinalities without a per-cell rationale. Crossing axes is allowed only when each cell maps to a distinct code path the author can name; otherwise the stack is a performance sweep, not a UT.
|
||||
- [ ] **Test layer hygiene.** Tests must not import from `benchmarks/` per `docs/design/trust-model.md`. Cross-layer touches are judged by `pre-review.md` criteria (oracle origin, coverage set, new-path coverage).
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
- Every `tileops/kernels/*` subpackage MUST have an `__init__.py` with explicit `__all__` and `from .module import Symbol` re-exports.
|
||||
|
||||
- Intra-package imports: relative (`from .op import Op`). Cross-package: absolute (`tileops.foo.bar`).
|
||||
|
||||
- No file-level lint suppressions (`# ruff: noqa`, `# flake8: noqa`). Use targeted inline `# noqa: XXXX` only.
|
||||
|
||||
- TIR parameter type: `T.Tensor(shape, dtype)`, never the deprecated `T.Buffer`.
|
||||
|
||||
- Reinterpret cast: `T.reinterpret(value, dtype)` (value first), never the deprecated dtype-first form.
|
||||
|
||||
- Each TileLang kernel is one `@T.prim_func` whose body opens `with T.Kernel(...)`; sub-routines use `@T.macro`, never nested `prim_func`.
|
||||
|
||||
- No narrow-type literal casts (`T.cast(1.0, "float16")`). Reference `x.dtype`, or compute in a wider intermediate and cast at the boundary.
|
||||
|
||||
- Promote overflow-prone fp16/bf16 math (cubic, division, `exp`, softmax accumulators) to fp32; cast back to storage dtype at the boundary.
|
||||
|
||||
- Decorate each `_<op>_kernel` builder (the `@tilelang.jit`-wrapping `Callable`) with `@functools.lru_cache(maxsize=<N>)`; every parameter must be hashable. Default `maxsize=32`; use `64` only when the distinct-config working set demands it; `maxsize=None` only for intrinsically bounded config spaces. Document any non-default choice at the call site.
|
||||
|
||||
- Tag tests degraded by a process constraint (e.g. trust model splitting manifest and code PRs) with `FIXME(staged-rollout)`. Cleanup line names the invariant to restore — never a PR number. Scan: `grep -rn 'FIXME(staged-rollout)'`.
|
||||
|
||||
```python
|
||||
# FIXME(staged-rollout): <one-line summary of what's degraded>
|
||||
#
|
||||
# Broken invariant: <what contract is currently violated>
|
||||
# Why: <which process constraint requires this temporary state>
|
||||
# Cleanup: <concrete condition that triggers removal of this marker>
|
||||
```
|
||||
|
||||
- PascalCase abbreviations stay fully uppercase: `RMSNormKernel`, `SSDDecodeOp`, `FusedAddRMSNormFwdOp`.
|
||||
|
||||
- Filenames: all-lowercase with underscores. Multi-letter abbreviations stay lowercase (`rms_norm.py`, `ssd_decode.py`); never capitalize a single letter. Never contract norm names (`rms_norm`, not `rmsnorm`).
|
||||
|
||||
- Docstrings: Google style. One-line summary, blank line, then optional `Args:` / `Returns:` / `Raises:` / `Example:`. Internal helpers may use a single-line summary. Never mix Sphinx (`:param:`) or NumPy headers in one file.
|
||||
|
||||
- Expand domain abbreviations on first use in a docstring: `State Space Model (SSM)`, `State-Space Dual (SSD)`. Later uses may abbreviate.
|
||||
|
||||
- Shipped source (code, docstrings, manifest YAML) must not reference issue/PR numbers, AC labels, round numbers, reviewer names, or `Follow-up: #N`. See [domain-rules/manifest-spec.md](../domain-rules/manifest-spec.md).
|
||||
|
||||
Discovery scan: `grep -rnE '(^|[^[:alnum:]])#[0-9]{3,}|AC-[0-9]+|round-[0-9]+ review|[Ff]ollow-up:[[:space:]]*#' --exclude-dir=manifest tileops/ tests/ benchmarks/ scripts/`
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
- For the OWNS / boundary chart of the manifest stage, see [docs/design/trust-model.md](../../docs/design/trust-model.md) §Manifest.
|
||||
- When the op name matches a PyTorch op (`torch.nn.*`, `torch.nn.functional.*`), the manifest signature must match PyTorch's public API. Do not invent parameters.
|
||||
- Implementation does not conform to spec → set `status: spec-only`, fix code in follow-up PR. Never modify manifest to match code.
|
||||
- Do not remove `roofline.vars`, `shape_rules`, or `params` to silence validator errors.
|
||||
|
||||
## Status flip carve-out
|
||||
|
||||
An implementation PR may edit the aligned op's manifest entry only at:
|
||||
|
||||
- `status` (any direction).
|
||||
- `source.kernel_map` entries.
|
||||
- `source.test` and `source.bench` path values. Discoverability pointers, not contractual fields — may be retargeted at the per-op test/bench file authored by the same PR.
|
||||
- `workloads` — **only** when the same PR flips `status: spec-only → implemented` on that op (promotion forces non-empty workloads to satisfy `test_every_op_has_at_least_two_workloads`).
|
||||
- `torch_compile_fullgraph` — **only** together with its registered compile-test evidence.
|
||||
|
||||
Every other field — `family`, `ref_api`, `signature`, `roofline.*`, `params`, `output-dtype`, `shape_rules`, `source.kernel`, `source.op`, `source.bench_manifest_driven`, and any other `workloads` edit — needs a separate manifest-only PR with human review.
|
||||
|
||||
The carve-out narrows the prohibition; it does not relax the trust boundary.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
- **Never** hardcode secrets, tokens, passwords, or API keys in any file.
|
||||
- **Never** print or echo secrets in GitHub workflow steps (e.g. `echo ${{ secrets.XXX }}`). Use environment variables and let GitHub mask them automatically.
|
||||
- **Always** review GitHub workflow changes for secret exposure before committing: check `env:` blocks, `run:` scripts, and `with:` parameters.
|
||||
- **Never** commit `.env`, `.pem`, `.key`, `credentials.json`, or similar sensitive files. These are in `.gitignore`.
|
||||
- When creating or modifying workflows, use `${{ secrets.GITHUB_TOKEN }}` via `env:` — never inline in shell commands.
|
||||
- Gitleaks runs as a pre-commit hook to catch leaked secrets. Do not bypass it (`SKIP=gitleaks` is not allowed without explicit user approval).
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
---
|
||||
name: add-manifest
|
||||
description: Generate or re-align one `tileops/manifest/` entry from a reference-API docs URL. Caller provides the manifest key (`op_name`); skill writes that one entry. Idempotent.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
| Argument | Required | Description |
|
||||
| --------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `op_name` | Yes | Manifest key (e.g., `RMSNormFwdOp`). Caller-supplied, never derived. For variants, caller invokes once per emitted key. |
|
||||
| `ref_url` | Yes | HTTPS docs URL for the Tensor op. Must match `^https://[A-Za-z0-9./_-]+\.html$`. |
|
||||
|
||||
## Contract
|
||||
|
||||
**One entry per invocation.** No splitting, no variant orchestration. For primary + variant, caller invokes the skill twice with different `op_name`s.
|
||||
|
||||
**Idempotent.** Auto-derivable fields are rewritten from the reference; human-curated fields are preserved if the entry exists, defaulted otherwise.
|
||||
|
||||
| Auto-derivable (always rewritten from reference) | Human-curated (preserved if entry exists, else default) |
|
||||
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `signature.{inputs,outputs,params}` | `family` (default: from sibling-entry copy or BLOCKED) |
|
||||
| `signature.shape_rules` | `ref_api` (default: derived from `ref_url`'s last path segment) |
|
||||
| `signature.dtype_combos` | `workloads` (default: `[]`) |
|
||||
| `roofline.{flops,bytes,vars}` (well-known op) | `source.{kernel,op,test,bench,kernel_map,bench_manifest_driven}` (default: from RESOLVE_SOURCES + `bench_manifest_driven: false`) |
|
||||
| | `status` (default: `spec-only`) |
|
||||
| | Adjacent comments (best-effort) |
|
||||
|
||||
**Termination**: draft PR created → success. Invalid URL / un-derivable roofline / source-path or family resolution failure → BLOCKED.
|
||||
|
||||
**Constraints**: never edit op / kernel / test / bench code. Never invent params outside the reference. Never set `status: implemented` (that is `align-op@FLIP_STATUS`).
|
||||
|
||||
**File to edit**: write the entry into `tileops/manifest/<family>.yaml`, where `<family>` is the entry's `family` field. The manifest is split one file per family; do not create new files or move entries between files. Use `ruamel.yaml` for round-trip preservation of comments and key order.
|
||||
|
||||
**Caller responsibility**: `op_name` and `ref_url` must point at the same op. The skill does not enforce alignment between them — TileOPs identity may legitimately differ from any reference's naming (e.g., `MultiHeadAttentionFwdOp` ↔ `torch.nn.functional.scaled_dot_product_attention`). Wrong pairing produces a broken manifest entry silently.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> VALIDATE_INPUT
|
||||
VALIDATE_INPUT --> [*]: invalid
|
||||
VALIDATE_INPUT --> READ_EXISTING
|
||||
READ_EXISTING --> READ_REFERENCE: snapshot saved (entry present)
|
||||
READ_EXISTING --> RESOLVE_SOURCES: entry absent
|
||||
RESOLVE_SOURCES --> READ_REFERENCE
|
||||
RESOLVE_SOURCES --> [*]: source / family resolution failed → BLOCKED
|
||||
READ_REFERENCE --> DRAFT_ENTRY
|
||||
DRAFT_ENTRY --> VALIDATE
|
||||
VALIDATE --> DRAFT_ENTRY: L0 fail
|
||||
VALIDATE --> RUN_AUDIT: L0 pass
|
||||
RUN_AUDIT --> CREATE_ISSUE
|
||||
CREATE_ISSUE --> CREATE_PR
|
||||
CREATE_PR --> [*]
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. VALIDATE_INPUT
|
||||
|
||||
Reject `ref_url` not matching the regex. Reject `op_name` not matching `^[A-Z][A-Za-z0-9]+(Fwd|Bwd)Op$`.
|
||||
|
||||
### 2. READ_EXISTING
|
||||
|
||||
Look up `op_name` in `tileops/manifest/`.
|
||||
|
||||
- **Present** → snapshot the human-curated fields per the Contract table. Source paths come from the existing `source.*`. Proceed to READ_REFERENCE.
|
||||
- **Absent** → greenfield. Proceed to RESOLVE_SOURCES.
|
||||
|
||||
### 3. RESOLVE_SOURCES (greenfield only)
|
||||
|
||||
Lookup is **class-based**, not filename-based — many TileOPs ops share a file (e.g., `SumFwdOp` and `MeanFwdOp` both in `tileops/ops/reduction/reduce.py`).
|
||||
|
||||
1. **`source.op`**: scan `tileops/ops/**/*.py` for `class <op_name>(...)` (AST or `grep -rlE "^class <op_name>\(" tileops/ops/`).
|
||||
- Exactly one match → that file path.
|
||||
- Zero matches → true greenfield. Default to `tileops/ops/<snake_name>.py` (use a family subdirectory if a sibling-family entry suggests one). `<snake_name>` = `op_name` minus trailing `FwdOp` / `BwdOp`, snake_cased (`RMSNormFwdOp` → `rms_norm`). File may not exist yet; caller scaffolds afterward.
|
||||
- Multiple → BLOCKED disambiguation.
|
||||
1. **`source.kernel`** (required by L0; `fix-manifest` cannot fill it later):
|
||||
- If `source.op` was found by class lookup: read its imports for a `Kernel` subclass; apply class-lookup under `tileops/kernels/**/*.py`. One match → that file. Multiple → BLOCKED disambiguation.
|
||||
- No kernel import (kernel-less op) → `source.kernel = source.op`.
|
||||
- Otherwise → BLOCKED `evidence_needed: source.kernel for <op_name>`.
|
||||
1. `source.test = tests/ops/test_<snake_name>.py`; `source.bench = benchmarks/ops/bench_<snake_name>.py`. Missing files: record absent.
|
||||
1. **`family`** (required by L0; cannot be empty):
|
||||
- Copy from a sibling manifest entry whose `source.op` parent-dir or basename overlaps.
|
||||
- No matching sibling → BLOCKED `evidence_needed: family for <op_name>`. Never invent.
|
||||
|
||||
### 4. READ_REFERENCE
|
||||
|
||||
`WebFetch(ref_url)`. Sole source of truth.
|
||||
|
||||
| Reference param kind | Goes to |
|
||||
| -------------------- | -------------------------------------- |
|
||||
| Tensor | `signature.inputs` (positional order) |
|
||||
| non-Tensor | `signature.params` (`type`, `default`) |
|
||||
| return | `signature.outputs` |
|
||||
|
||||
Names match the reference verbatim. Include every reference param even if the kernel ignores it. Exclude `float64` and `complex32/64/128` (TileOPs is GPU-only).
|
||||
|
||||
For references with `Optional[Tensor]` inputs, the caller has decided which slice corresponds to `op_name` (primary = required only; variant = primary + chosen optional). The skill emits inputs accordingly.
|
||||
|
||||
### 5. DRAFT_ENTRY
|
||||
|
||||
Snapshot present (re-align) → preserve human-curated fields verbatim. Snapshot absent (greenfield) → use Contract defaults. Auto-derivable fields:
|
||||
|
||||
- `signature.inputs`: ordered dict in the reference's positional order. Per input: `dtype` = supported set joined with `|` (reference dtypes minus `float64` and complex types); `shape` only if fixed rank; `layout` only if non-default; `constraints` if applicable.
|
||||
- `signature.outputs`: same shape as inputs. Use `same_as(<ref>)` where applicable.
|
||||
- `signature.params`: ordered dict, each `{type, default}`.
|
||||
- `signature.shape_rules`: Python expressions for derived dims and inter-tensor constraints.
|
||||
- `signature.dtype_combos`: only if supported set ⊂ Cartesian product; else omit.
|
||||
- `roofline`: required by L0. Well-known op (conv / pool / matmul / norm / reduction): standard formula. Fixed-rank: shape names auto-bind, use `elem_bytes`. Arbitrary-rank: `vars` mapping. Not derivable → BLOCKED `evidence_needed: roofline.flops|bytes for <op_name>`.
|
||||
|
||||
### 6. VALIDATE
|
||||
|
||||
```bash
|
||||
python scripts/validate_manifest.py --check-op <op_name>
|
||||
```
|
||||
|
||||
L0 must pass. On fail: edit entry, rerun. L1–L4 failures go to the follow-up issue, not blocking.
|
||||
|
||||
### 7. RUN_AUDIT
|
||||
|
||||
Invoke `audit-family` for the op's family → `.foundry/migrations/<family>.json`.
|
||||
|
||||
### 8. CREATE_ISSUE
|
||||
|
||||
Invoke `foundry:creating-issue`. Per `semantic_gap` op the body MUST contain: kernel feasibility (cite kernel code; classify each missing param `trivial` / `kernel-change` / `blocked`); class-structure impact; effort per gap item; family dependencies. MUST also list outstanding human decisions (`workloads`, `roofline`) and resolution path. MUST NOT duplicate validator-reported facts. Record the issue URL.
|
||||
|
||||
### 9. CREATE_PR
|
||||
|
||||
Invoke `foundry:creating-pull-request` (draft):
|
||||
|
||||
| Snapshot at READ_EXISTING | Title | Branch |
|
||||
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------- |
|
||||
| absent | `[Maintain][Manifest] Add <op_name>` | `maintain/manifest/<op-slug>` |
|
||||
| present | `[Refactor][Manifest] Re-align <op_name> spec to <ref_api>` | `refactor/manifest/regenerate-<op-slug>` |
|
||||
|
||||
Body: which fields were rewritten vs. preserved, validator results, `Related: #<issue from step 8>`. Title and branch must match `.claude/conventions/types.sh`.
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
---
|
||||
name: align-family
|
||||
description: Drive the full migration for an op family — audit, delegate per-op alignment to align-op, run cross-op cleanup. Two terminal outcomes: SUCCESS opens a PR; CLEANUP_REGRESSION exits blocked without a PR when post-cleanup tests fail.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
Family name from `tileops/manifest/` (e.g., `reduction`, `norm`, `attention`).
|
||||
|
||||
## Contract
|
||||
|
||||
- **Input**: `family` name
|
||||
- **Output** (two terminal outcomes):
|
||||
- **SUCCESS**: PR URL + final report — all ops processed via `align-op` (promoted or blocked), cleanup succeeds, PR opens.
|
||||
- **BLOCKED**: blocked report (no PR) — reached when CLEANUP detects a regression in a promoted op's tests after dual-path removal; see `CLEANUP_REGRESSION` terminal in the state diagram. Distinct from the non-terminal per-op `REPORT_BLOCKED` state.
|
||||
- **Termination**: all ops processed (promoted or blocked via `align-op`) and either (a) CLEANUP + CREATE_PR succeed, or (b) a promoted op's tests fail after CLEANUP's dual-path removal, causing the run to exit via `CLEANUP_REGRESSION` with the regression recorded. (`REPORT_BLOCKED` is the non-terminal per-op blocked state and never terminates the run.)
|
||||
|
||||
## Trust Model
|
||||
|
||||
- `align-family` delegates every per-op stage to `align-op`, invoked as a **separate sub-agent** per op. The family orchestrator never runs any atomic per-op skill directly — those live inside `align-op`'s contract.
|
||||
|
||||
- `align-family` does **not** write `tileops/manifest/`. After the refactor, `align-op` is the sole manifest writer (at its own FLIP_STATUS step); `align-family` observes status transitions via `align-op`'s SUCCESS return. No `align-family` stage edits, modifies, or flips the manifest.
|
||||
|
||||
- Directly-invoked sub-skills of `align-family` are exactly two: `audit-family` (in AUDIT) and `align-op` (per op).
|
||||
|
||||
| Stage | Sub-skill |
|
||||
| -------- | -------------- |
|
||||
| AUDIT | `audit-family` |
|
||||
| ALIGN_OP | `align-op` |
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> AUDIT
|
||||
AUDIT --> GROUP_BY_BASE: gap report generated
|
||||
GROUP_BY_BASE --> ROUTE: ops grouped by base class
|
||||
ROUTE --> ALIGN_OP: ready (mode=minor) or semantic_gap (mode=redesign)
|
||||
ROUTE --> REPORT_BLOCKED: classification=blocked (audit-family)
|
||||
ALIGN_OP --> CLEANUP_GATE: align-op SUCCESS
|
||||
ALIGN_OP --> REPORT_BLOCKED: align-op BLOCKED
|
||||
REPORT_BLOCKED --> CLEANUP_GATE: record blocked reason
|
||||
CLEANUP_GATE --> ROUTE: group incomplete, next op
|
||||
CLEANUP_GATE --> CLEANUP: all siblings promoted or blocked
|
||||
CLEANUP --> ROUTE: legacy path removed, next group
|
||||
ROUTE --> CREATE_PR: all ops processed
|
||||
CLEANUP --> CLEANUP_REGRESSION: promoted op test fails after cleanup
|
||||
CLEANUP_REGRESSION --> [*]: terminal blocked, no PR
|
||||
CREATE_PR --> [*]
|
||||
```
|
||||
|
||||
## Orchestrator Discipline
|
||||
|
||||
### Clean worktree between sub-agents
|
||||
|
||||
After each sub-agent returns and before dispatching the next, verify:
|
||||
|
||||
```bash
|
||||
test -z "$(git status --porcelain)"
|
||||
```
|
||||
|
||||
This catches tracked changes, staged changes, AND untracked files. If not clean: the sub-agent's commit failed (pre-commit hook, staging issue) or left new files uncommitted. Orchestrator commits on behalf, then proceeds. Every agent must start with a clean worktree.
|
||||
|
||||
### Dual-path is acceptable during migration
|
||||
|
||||
When `align-op` rewrites a base class during its per-op pipeline, it may create a dual-path `__init__` (legacy + spec) to keep unmigrated sibling tests passing. This is correct temporary debt — the cleanup gate removes it.
|
||||
|
||||
**Dual-path definition**: a class `__init__` with runtime branching to support two incompatible construction interfaces, and `forward` dispatching to two execution paths. Not polymorphism — same semantics, temporary interface coexistence.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. AUDIT
|
||||
|
||||
```
|
||||
/audit-family <family>
|
||||
```
|
||||
|
||||
Gap report written to `.foundry/migrations/<family>.json`.
|
||||
|
||||
### 2. GROUP_BY_BASE
|
||||
|
||||
Group ops by `base_class` from the gap report. Each group is a set of sibling ops sharing a base class. Process groups in order; within each group, process ops in order (first op likely fixes the base class, subsequent ops validate).
|
||||
|
||||
`base_class` is a required field in the gap report. audit-family must populate it for every op entry. If an op inherits `Op` directly (no intermediate base class), its `base_class` is `"Op"` — these ops form a single group but are independent (no shared base class to rewrite, so cleanup gate is a no-op for this group).
|
||||
|
||||
Track group completion: a group is complete when all its ops are `promoted` or `blocked`.
|
||||
|
||||
### 3. ROUTE
|
||||
|
||||
Read the gap report from AUDIT. For each op in the current group, route by `classification` (the field `audit-family` populates):
|
||||
|
||||
| Gap-report `classification` | Action | Why |
|
||||
| --------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ready` | → ALIGN_OP with `--mode=minor` | Existing code already conforms; align-op's minor path runs the spec tests (DONE_SKIP since they pass), then the shared downstream + flip. |
|
||||
| `semantic_gap` | → ALIGN_OP with `--mode=redesign` | Family-scoped historical migration treats every `semantic_gap` op as a structural redesign by default — legacy code is being replaced wholesale. For per-op `semantic_gap` cases that are actually minor manifest deltas, use single-op `align-op <op> --mode=minor` directly instead of `align-family`. A future `recommended_mode` gap-report field could refine the routing automatically. |
|
||||
| `blocked` | → REPORT_BLOCKED with audit's `reason`. Skip align-op. | Audit determined the op cannot be migrated autonomously (no `pytorch_equivalent`, kernel-layer change required, etc.). No per-op work to do. |
|
||||
|
||||
The mapping is deterministic — `align-family` MUST pass `--mode=` explicitly so `align-op` never falls into its interactive prompt branch in a batch family migration.
|
||||
|
||||
### 4. ALIGN_OP (per op)
|
||||
|
||||
For each op routed here, invoke `align-op` as a **separate sub-agent** with the mode determined in Step 3:
|
||||
|
||||
```
|
||||
align-op <op_name> --mode=<minor|redesign>
|
||||
```
|
||||
|
||||
`align-op` owns the entire per-op pipeline internally — its internal stages (classify, dispatch on case, test / implement / bench, revalidate, flip status, cleanup, report) are `align-op`'s contract, not `align-family`'s. See [`align-op/SKILL.md`](../align-op/SKILL.md) for the authoritative stage list and the conditional-IMPLEMENT rule. `align-family` does not manage or observe `align-op`'s internal stages; the only interface between them is `align-op`'s SUCCESS / BLOCKED return.
|
||||
|
||||
Per-op outcome:
|
||||
|
||||
- `align-op` returns SUCCESS → op is `promoted` (manifest status already flipped by `align-op`'s FLIP_STATUS). Record the returned report. Proceed to CLEANUP_GATE.
|
||||
- `align-op` returns BLOCKED → op is `blocked`. Capture `align-op`'s BLOCKED reason verbatim as the per-op result. Proceed to CLEANUP_GATE.
|
||||
|
||||
`align-family` MUST NOT re-flip manifest status or re-run per-op validation on its own; `align-op`'s SUCCESS return is the single source of truth that the manifest was flipped.
|
||||
|
||||
The flip performed by `align-op` MUST stay within the [Status flip carve-out](../../rules/manifest-trust-model.md#status-flip-carve-out); `align-family` MUST NOT batch contractual-field edits across ops in the same PR.
|
||||
|
||||
### 5. CLEANUP_GATE
|
||||
|
||||
After each per-op outcome — whether `align-op` returned SUCCESS / BLOCKED or ROUTE recorded an audit-classified `blocked` op directly to `REPORT_BLOCKED` — check group completion:
|
||||
|
||||
- All siblings in the current base-class group are `promoted` or `blocked`? → trigger CLEANUP
|
||||
- Otherwise → continue to next op (ROUTE → ALIGN_OP)
|
||||
|
||||
### 6. CLEANUP
|
||||
|
||||
Remove dual-path legacy code from the base class. This step fires once per base-class group, after all siblings have gone through `align-op` (SUCCESS or BLOCKED).
|
||||
|
||||
Actions:
|
||||
|
||||
1. Remove legacy `__init__` branch (`if M is not None and N is not None` path)
|
||||
1. Remove `_legacy` flag and `_forward_legacy` method
|
||||
1. Remove `M`, `N` keyword-only parameters from `__init__`
|
||||
1. Run tests and `--check-op` for **promoted ops only** (blocked ops' tests may legitimately fail)
|
||||
1. Commit cleanup changes
|
||||
|
||||
If any promoted op's test fails after cleanup → transition to `CLEANUP_REGRESSION` (terminal): record the regression, skip `CREATE_PR`, exit with `blocked` status. Do not proceed with a broken state.
|
||||
|
||||
**Two distinct blocked states**, split so the diagram has one meaning per state:
|
||||
|
||||
- **`REPORT_BLOCKED`** — per-op `align-op` returned BLOCKED. Records the reason, returns to `CLEANUP_GATE`, continues with sibling ops. Non-terminal.
|
||||
- **`CLEANUP_REGRESSION`** — a promoted op's tests fail after CLEANUP's dual-path removal. Terminal: the family migration exits without opening a PR. The blocked report becomes the run's final artefact.
|
||||
|
||||
**Timeout policy for blocked ops**: if a group has blocked ops that prevent the cleanup gate from firing for an extended period, the orchestrator may force cleanup — remove legacy path and mark blocked ops' tests as `xfail`. This is a human decision, not automatic.
|
||||
|
||||
### 7. CREATE_PR
|
||||
|
||||
After all ops processed:
|
||||
|
||||
- Collect all per-op reports returned by `align-op`
|
||||
- Create PR with:
|
||||
- Migration summary (promoted / blocked counts)
|
||||
- Per-op change table (derived from `align-op` reports)
|
||||
- Observations surfaced by `align-op` (e.g., `needs_kernel_work`, `needs_human_decision`) for human doc review
|
||||
- Blocked ops with `align-op`'s BLOCKED reasons
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
---
|
||||
name: align-op
|
||||
description: Per-op orchestrator that brings a single op into alignment with its manifest entry. Classifies the op into one of three cases (green field / interface redesign / minor delta), dispatches to the right path (scaffold-op for new, archive+rescaffold+port for redesign, implement-op for minor), then runs the shared downstream (test → bench → validate → flip status → report). Complements the family-scoped `align-family`; per-op entry when you know the op you want to touch.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
- `op_name` (positional) — manifest key, e.g. `CumsumFwdOp`.
|
||||
- `--mode=green|redesign|minor` (optional) — override the automatic classification. When omitted, `CLASSIFY` decides (auto if unambiguous, otherwise prompt).
|
||||
- `--classify-only` (optional) — stop after `CLASSIFY`; write `mode.json` and return without executing any case path. Use to ask "which case is this op in?" without side effects.
|
||||
|
||||
## Contract
|
||||
|
||||
- **Input**: `op_name` must be present in [`tileops/manifest/`](../../../tileops/manifest/) with `status: spec-only` and a non-empty `source.kernel_map` (same preconditions as scaffold-op; see [PRE_CHECK](#pre_check)).
|
||||
- **Path and data bindings used throughout this skill** (resolved by the orchestrator once at `PRE_CHECK` when the manifest entry is first loaded, then passed into every sub-skill invocation):
|
||||
- `<source_op>` — manifest `source.op` path (e.g., `tileops/ops/reduction/cumsum.py`).
|
||||
- `<source_test>` — manifest `source.test` path (e.g., `tests/ops/test_cumulative.py`).
|
||||
- `<source_bench>` — manifest `source.bench` path.
|
||||
- `<source_kernel>` — manifest `source.kernel` path (the primary kernel implementation file).
|
||||
- `<manifest_signature>` — the `signature` sub-tree from the op's manifest entry, passed verbatim to test-op / implement-op.
|
||||
- `<pytorch_equivalent>` — manifest `ref_api` value (e.g., `"torch.cumsum"`) or `null` if the op has no PyTorch reference. Required by test-op.
|
||||
- **Output** (SUCCESS path): op file at `source.op` aligned with the manifest; test file `source.test` aligned; `__init__.py` registrations consistent; `status` flipped `spec-only → implemented` (single commit). Side-artefacts in `.foundry/plan/<op_name>/`: `mode.json` (classification), `plan.json` (scaffold-op's §1/§2/§3 when that skill ran), `kernel-check.json` (redesign case only), `pre-rewrite/source.py` (redesign case, removed at CLEANUP on SUCCESS).
|
||||
- **Termination (success)**: `python scripts/validate_manifest.py --check-op <op_name>` reports no errors + `python -m pytest <source_test> -v` passes + benchmark produces numbers + manifest status flipped.
|
||||
- **Termination (blocked)**: any sub-skill (scaffold-op / test-op / implement-op / bench-op) returns blocked; or scaffold-op §1 drift; or REVALIDATE fails. Kernel-layer mismatches surfaced by `KERNEL_CHECK` are **informational only** and never cause BLOCKED by themselves — BLOCKED is reached only if a kernel drift propagates into a downstream sub-skill failure (e.g., bench-op runtime error, REVALIDATE regression). Archives are kept for post-mortem.
|
||||
- **Constraints**:
|
||||
- Only align-op (and only at FLIP_STATUS) may modify `tileops/manifest/`. Sub-skills never touch the manifest.
|
||||
- MUST NOT modify kernel code. Kernel-layer work, if needed, is surfaced via `kernel-check.json` as a separate follow-up.
|
||||
- MUST NOT expand to multi-op scope; that is `align-family`'s role.
|
||||
|
||||
## Trust model
|
||||
|
||||
- `CLASSIFY`, `DISPATCH`, `FLIP_STATUS`, `CLEANUP`, `REPORT` are orchestrator stages (align-op itself). Every other stage delegates to an atomic skill as a **separate sub-agent invocation**:
|
||||
|
||||
| Stage | Sub-skill |
|
||||
| ------------- | ------------------------------------------------------------ |
|
||||
| GREEN path | `scaffold-op` |
|
||||
| REDESIGN path | `scaffold-op` (after ARCHIVE + CLEAR) |
|
||||
| MINOR path | `implement-op` |
|
||||
| TEST | `test-op` |
|
||||
| IMPLEMENT | `implement-op` (green / redesign only; minor already did it) |
|
||||
| BENCH | `bench-op` |
|
||||
|
||||
Separate invocations preserve the per-skill contracts (e.g., scaffold-op's §1 fact-freeze; implement-op's no-test-modification rule).
|
||||
|
||||
- After each sub-skill returns, align-op verifies `git status --porcelain` is empty before dispatching the next. If a sub-skill left an uncommitted change, align-op commits on its behalf with `Sub-skill [name]: [summary]` before proceeding.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> PRE_CHECK
|
||||
PRE_CHECK --> CLASSIFY: manifest preconditions pass
|
||||
PRE_CHECK --> BLOCKED: prereq missing
|
||||
CLASSIFY --> CLASSIFY_ONLY_EXIT: --classify-only flag
|
||||
CLASSIFY --> DISPATCH: mode decided (auto or --mode or user-declared)
|
||||
DISPATCH --> GREEN_PATH: case = green
|
||||
DISPATCH --> REDESIGN_PATH: case = redesign
|
||||
DISPATCH --> MINOR_PATH: case = minor
|
||||
GREEN_PATH --> TEST: scaffold-op succeeded
|
||||
REDESIGN_PATH --> KERNEL_CHECK: rescaffold + port done
|
||||
KERNEL_CHECK --> TEST: kernel-check.json written
|
||||
MINOR_PATH --> TEST: implement-op succeeded (minor-case main stage ran here)
|
||||
TEST --> IMPLEMENT: tests fail on current code (expected gap to close)
|
||||
TEST --> BENCH: tests already pass (DONE_SKIP), usually minor path
|
||||
IMPLEMENT --> BENCH: implementation closes the gap, tests pass
|
||||
IMPLEMENT --> BLOCKED: gap beyond op-layer (e.g. kernel rewrite required)
|
||||
BENCH --> REVALIDATE: benchmark produces numbers
|
||||
REVALIDATE --> FLIP_STATUS: check-op and pytest both pass
|
||||
REVALIDATE --> BLOCKED: regression
|
||||
FLIP_STATUS --> CLEANUP: manifest status flipped
|
||||
CLEANUP --> REPORT: pre-rewrite dropped on redesign, plan artefacts kept
|
||||
REPORT --> [*]
|
||||
CLASSIFY_ONLY_EXIT --> [*]
|
||||
GREEN_PATH --> BLOCKED: scaffold failed (§1 drift or validator error)
|
||||
REDESIGN_PATH --> BLOCKED: scaffold or port failed
|
||||
MINOR_PATH --> BLOCKED: implement-op failed
|
||||
BLOCKED --> [*]: return to caller with reason
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### <a id="pre_check"></a>1. PRE_CHECK
|
||||
|
||||
Preconditions identical to `scaffold-op`'s — orchestrator enforces them up front so sub-skills never see ill-formed input:
|
||||
|
||||
- `op_name` in `tileops/manifest/` → proceed; otherwise BLOCKED ("op not in manifest").
|
||||
- `status: spec-only` → proceed; `implemented` → BLOCKED ("already aligned; flip status to spec-only in a manifest PR first if you intend to re-align"); missing/other → BLOCKED.
|
||||
- `source.kernel_map` declared and non-empty → proceed; missing → BLOCKED with the same guidance scaffold-op uses (add in a prerequisite manifest PR).
|
||||
- Every value in `source.kernel_map` resolves to an importable symbol → proceed; otherwise BLOCKED ("kernel class not found at expected path" — kernel must exist for op layer to align, regardless of case).
|
||||
|
||||
### 2. CLASSIFY
|
||||
|
||||
Decide which case applies. Machine-decidable input: does `source.op` exist?
|
||||
|
||||
| Input | Auto case | User prompt? |
|
||||
| -------------------------- | --------- | ----------------------------------------------------------- |
|
||||
| `source.op` does not exist | `green` | no |
|
||||
| `source.op` exists | (unknown) | yes — "redesign (rewrite + port) or minor (in-place edit)?" |
|
||||
|
||||
`--mode=<case>` overrides prompting only when consistent with file presence. The orchestrator validates this during CLASSIFY and BLOCKs immediately on invalid combinations so sub-skills never see contradictory input:
|
||||
|
||||
- `source.op` **missing** → only `green` is valid. `--mode=minor` → BLOCKED ("`source.op` is missing; cannot edit a non-existent op file; use `--mode=green` or omit `--mode`"). `--mode=redesign` → BLOCKED ("`source.op` is missing; no archive source to rewrite; use `--mode=green` or omit `--mode`").
|
||||
- `source.op` **exists** → `--mode=green` → BLOCKED ("`source.op` already exists; green-field scaffold would silently overwrite; use `--mode=redesign` for rewrite+port or `--mode=minor` for in-place edit").
|
||||
|
||||
**First-op bias.** If no op in the same `family` has `status: implemented` and follows the canonical pattern (`docs/design/ops-design.md` § Step 3), set `auto-case = redesign` (skip the prompt). mode.json: `decided_by: "auto"`, `reason: "no canonical-pattern precedent in family <name>"`. User may override with `--mode=minor`.
|
||||
|
||||
Write `.foundry/plan/<op_name>/mode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"op_name": "CumsumFwdOp",
|
||||
"case": "redesign",
|
||||
"file_present": true,
|
||||
"kernel_class_importable": true,
|
||||
"decided_by": "user_prompt",
|
||||
"reason": "User declared: manifest _static_axes shape rewritten; structural redesign.",
|
||||
"classified_at": "YYYY-MM-DDTHH:MM:SSZ"
|
||||
}
|
||||
```
|
||||
|
||||
`decided_by` is one of `auto` / `user_prompt` / `flag_override`. `reason` is free-form text.
|
||||
|
||||
If `--classify-only` was passed, terminate here and print the mode.json content. No other side effects.
|
||||
|
||||
### 3. DISPATCH — case-specific main stage
|
||||
|
||||
Each path produces the aligned op file under `source.op` plus whatever artefacts the sub-skill creates under `.foundry/plan/<op_name>/`.
|
||||
|
||||
#### 3a. GREEN path (`case = green`)
|
||||
|
||||
```
|
||||
scaffold-op <op_name>
|
||||
```
|
||||
|
||||
Sub-skill does PRE_CHECK → DRY_RUN (plan.json) → EMIT → REGISTER → VALIDATE → REPORT. align-op waits for SUCCESS or BLOCKED; on BLOCKED, surface the row and terminate.
|
||||
|
||||
#### 3b. REDESIGN path (`case = redesign`)
|
||||
|
||||
Sequence:
|
||||
|
||||
1. **ARCHIVE** — `mkdir -p .foundry/plan/<op_name>/pre-rewrite/`, copy `source.op` there as `source.py` (rename: strip family path, keep basename). The archive is the source of truth for manual porting. It persists until CLEANUP.
|
||||
1. **CLEAR** — remove `source.op` from the tree; remove the op's `from .<module> import <ClassName>` line and its `__all__` entry from the package `__init__.py`. Commit as `[Chore] align-op: archive <op_name> before rescaffold`.
|
||||
1. **SCAFFOLD** — `scaffold-op <op_name>`. Target now absent, PRE_CHECK passes, emits the 17 mechanical slots.
|
||||
1. **PORT** — read `pre-rewrite/source.py` and port op-specific content that the scaffold cannot produce:
|
||||
- Optional hooks (`_pad_value`, `_validate_dim`, `_pre_kernel`, `_post_kernel`, `_cache_key` override).
|
||||
- Family-specific protocol variables (`_op_kind`, `_kernel_key`, `_kernel_cls`, etc.) if the op was a T1 thin wrapper.
|
||||
- Any `forward` body specifics beyond the universal pattern (kernel-specific reshape/movedim choreography).
|
||||
- Any class-level non-slot attributes the old file had that still make sense under the new spec.
|
||||
Commit as `[Feat] align-op: port business logic for <op_name> from pre-rewrite`. If the agent is uncertain whether a specific override should be ported, record an `open_questions` item in plan.json §3 (`needs_human_decision`) and port conservatively.
|
||||
1. **KERNEL_CHECK** — see §5 below.
|
||||
|
||||
#### 3c. MINOR path (`case = minor`)
|
||||
|
||||
```
|
||||
implement-op(
|
||||
op_name=<op_name>,
|
||||
manifest_signature=<manifest_signature>,
|
||||
source_op=<source_op>,
|
||||
source_test=<source_test>
|
||||
)
|
||||
```
|
||||
|
||||
Sub-skill does ANALYZE → DIAGNOSE → IMPLEMENT → VALIDATE → MARK_DONE → COMMIT. align-op waits for SUCCESS or BLOCKED.
|
||||
|
||||
### 4. Skipped anchor (reserved)
|
||||
|
||||
### <a id="kernel_check"></a>5. KERNEL_CHECK (redesign path only)
|
||||
|
||||
Determine whether the kernel layer also needs work. align-op does **not** modify kernel code; it surfaces the question.
|
||||
|
||||
For each Kernel class referenced in `source.kernel_map`:
|
||||
|
||||
1. Inspect the kernel's `__init__` / `forward` / `_build_program` signatures (wherever applicable) in its source file.
|
||||
1. Compare against the new op's kernel-build call emitted by scaffold-op (`self.kernel_map[<key>](<args>)`). Specifically check:
|
||||
- Argument names and positional order.
|
||||
- Argument types.
|
||||
- Any layout / dtype expectations the kernel documents.
|
||||
1. Classify per kernel:
|
||||
- `aligned` — new op's kernel invocation matches the kernel's ctor; no kernel work.
|
||||
- `signature_drift` — arg names or order differ; kernel ctor must be adjusted.
|
||||
- `semantic_drift` — the kernel expects a different data layout / dtype than the new op provides (e.g. op now passes `(M, N)` where kernel expects `(N, M)`).
|
||||
- `unknown` — cannot determine from static inspection.
|
||||
|
||||
Write `.foundry/plan/<op_name>/kernel-check.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"op_name": "CumsumFwdOp",
|
||||
"checked_at": "YYYY-MM-DDTHH:MM:SSZ",
|
||||
"kernels": [
|
||||
{
|
||||
"dispatch_key": "cumulative_fwd",
|
||||
"kernel_class": "CumulativeKernel",
|
||||
"kernel_source": "tileops/kernels/reduction/cumulative.py",
|
||||
"classification": "aligned",
|
||||
"op_call": "self.kernel_map['cumulative_fwd'](M, N, 'sum', self.dtype, tune=self.tune)",
|
||||
"kernel_ctor": "__init__(self, M, N, op_kind, dtype, *, tune=False)",
|
||||
"notes": "Positional and named args match; no kernel work required."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Non-`aligned` entries surface in REPORT as `needs_kernel_work` follow-ups. align-op itself continues to TEST — the downstream path may still pass if the kernel drift only affects performance (not correctness), or fail fast if the kernel mismatch causes runtime errors, which REVALIDATE will catch.
|
||||
|
||||
### 6. TEST
|
||||
|
||||
```
|
||||
test-op(
|
||||
op_name=<op_name>,
|
||||
manifest_signature=<manifest_signature>,
|
||||
pytorch_equivalent=<pytorch_equivalent>,
|
||||
source_test=<source_test>
|
||||
)
|
||||
```
|
||||
|
||||
Sub-skill writes tests against the new spec. Termination:
|
||||
|
||||
- **tests fail on current code** (expected TDD seed) → proceed to IMPLEMENT.
|
||||
- **DONE_SKIP** (tests already pass, e.g. a sibling migration fixed the base class, or minor-path `implement-op` already closed the gap in Step 3c) → skip IMPLEMENT, proceed to BENCH.
|
||||
|
||||
### 7. IMPLEMENT
|
||||
|
||||
```
|
||||
implement-op(
|
||||
op_name=<op_name>,
|
||||
manifest_signature=<manifest_signature>,
|
||||
source_op=<source_op>,
|
||||
source_test=<source_test>
|
||||
)
|
||||
```
|
||||
|
||||
Closes the gap between the emitted op file and the tests from Step 6. Applies to:
|
||||
|
||||
- **Green path**: `scaffold-op` produced the 17 mechanical slots, but not optional hooks or family protocol vars; `implement-op` fills any that are required for the tests to pass.
|
||||
- **Redesign path**: the `PORT` sub-step in Step 3b did a first pass; `implement-op` closes residual gaps surfaced by the tests.
|
||||
- **Minor path**: **skipped** — `implement-op` already ran as the minor-case main stage in Step 3c. If TEST didn't DONE_SKIP here, that signals spec-drift beyond the minor-case scope and becomes BLOCKED.
|
||||
|
||||
BLOCKED if the gap requires kernel-layer changes (align-op is op-layer only; kernel work surfaces via `kernel-check.json` from KERNEL_CHECK or as a `blocked` return from `implement-op`).
|
||||
|
||||
### 8. BENCH
|
||||
|
||||
```
|
||||
bench-op(
|
||||
op_name=<op_name>,
|
||||
source_bench=<source_bench>,
|
||||
source_op=<source_op>
|
||||
)
|
||||
```
|
||||
|
||||
Produces numbers. Sub-skill unchanged. If BLOCKED and reason is not kernel-related, propagate blocked.
|
||||
|
||||
### 9. REVALIDATE
|
||||
|
||||
```bash
|
||||
python scripts/validate_manifest.py --check-op <op_name>
|
||||
python -m pytest <source_test> -v
|
||||
```
|
||||
|
||||
Both must pass. Regression after benchmark changes → BLOCKED.
|
||||
|
||||
### 10. FLIP_STATUS
|
||||
|
||||
Orchestrator (not a sub-skill) edits the manifest:
|
||||
|
||||
- `ops.<op_name>.status: spec-only` → `status: implemented`
|
||||
- Commit as `[Refactor][Manifest] promote <op_name> to implemented`.
|
||||
|
||||
This is the only manifest write in the entire workflow, and it MUST stay within the [Status flip carve-out](../../rules/manifest-trust-model.md#status-flip-carve-out); any contractual-field change requires a separate manifest-only PR.
|
||||
|
||||
### 11. CLEANUP
|
||||
|
||||
On SUCCESS path:
|
||||
|
||||
- Delete `.foundry/plan/<op_name>/pre-rewrite/` (redesign case only; archive purpose is served).
|
||||
- Keep `mode.json`, `plan.json`, `kernel-check.json` as audit trail — they are under `.foundry/plan/` which is gitignored but persists in the local worktree.
|
||||
|
||||
On BLOCKED path: keep all artefacts for post-mortem.
|
||||
|
||||
### 12. REPORT
|
||||
|
||||
Single-page summary printed to stdout. Always includes:
|
||||
|
||||
```
|
||||
Status: SUCCESS | BLOCKED
|
||||
Op: <op_name>
|
||||
Case: green | redesign | minor
|
||||
Mode decided by: auto | user_prompt | flag_override
|
||||
File: <source.op> (<lines>)
|
||||
|
||||
Sub-skills run:
|
||||
- scaffold-op: <SUCCESS|BLOCKED|skipped>
|
||||
- implement-op: <...>
|
||||
- test-op: <...>
|
||||
- bench-op: <...>
|
||||
|
||||
Plan artefacts (.foundry/plan/<op_name>/):
|
||||
- mode.json
|
||||
- plan.json (if scaffold-op ran)
|
||||
- kernel-check.json (if redesign path)
|
||||
- pre-rewrite/ (redesign path, cleaned on SUCCESS)
|
||||
|
||||
Status flipped: spec-only → implemented (commit <sha>)
|
||||
|
||||
Follow-ups:
|
||||
- <needs_kernel_work for kernel X> (from kernel-check.json non-aligned entries)
|
||||
- <needs_doc_fix for slot S21> (from plan.json §3)
|
||||
- <needs_human_decision about port of _pad_value> (from port observations)
|
||||
```
|
||||
|
||||
On BLOCKED, replace "Status flipped" line with the blocking error and list remaining follow-ups.
|
||||
|
||||
## Interaction with `align-family`
|
||||
|
||||
`align-family` is the family-scoped orchestrator and delegates every per-op stage to `align-op`. Its workflow is `AUDIT → GROUP_BY_BASE → ROUTE → (per op: ALIGN_OP) → CLEANUP_GATE → CLEANUP → CREATE_PR`; the family orchestrator never invokes the atomic per-op skills (`scaffold-op` / `test-op` / `implement-op` / `bench-op`) directly — every per-op stage runs inside `align-op`'s contract.
|
||||
|
||||
- Use `align-op <op>` for per-op work (green field, redesign, or minor delta).
|
||||
- Use `align-family <family>` for family-scoped historical migration of many ops at once.
|
||||
|
||||
They do not conflict. `align-op` never manages cross-op cleanup gates; that remains `align-family`'s. `align-op`'s `FLIP_STATUS` is the sole manifest-write site, observed by `align-family` via `align-op`'s SUCCESS return.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Kernel scaffolding / kernel-layer edits.** align-op surfaces kernel work as a follow-up via `kernel-check.json`; a separate (future) `kernel-scaffold` / `kernel-align` skill will own that layer.
|
||||
- **Family-level cleanup.** Cross-op dual-path removal lives in `align-family` and is not a concern of per-op alignment.
|
||||
- **General auto-detection of "redesign vs minor."** The distinction is a design judgement; align-op prompts or accepts `--mode`. The one exception is the **first-op bias** in CLASSIFY (no canonical-pattern precedent in the family → auto `redesign`). Beyond that one case, no auto-detection.
|
||||
- **Manifest changes (other than FLIP_STATUS).** Per the trust model, manifest changes live in separate manifest PRs.
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
---
|
||||
name: audit-family
|
||||
description: Compare each op's code signature against its manifest spec, classify gaps, produce a structured report.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
Family name from `tileops/manifest/` (e.g., `reduction`, `norm`, `attention`).
|
||||
|
||||
## Contract
|
||||
|
||||
- **Input**: `family` name
|
||||
- **Output**: `.foundry/migrations/<family>.json`
|
||||
- **Termination**: all ops classified. `ready` ops verified via `--check-op`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> LOAD_MANIFEST
|
||||
LOAD_MANIFEST --> FILTER: manifest loaded
|
||||
FILTER --> SELECT_OP: spec-only ops filtered by family
|
||||
SELECT_OP --> READ_CODE: op selected
|
||||
READ_CODE --> COMPARE_SIGNATURE: code inspected
|
||||
COMPARE_SIGNATURE --> CHECK_OP_VALIDATE: no signature difference
|
||||
COMPARE_SIGNATURE --> CHECK_PYTORCH_REF: difference found
|
||||
CHECK_OP_VALIDATE --> mark_ready: --check-op passes all levels
|
||||
CHECK_OP_VALIDATE --> CHECK_PYTORCH_REF: --check-op fails (deeper gap)
|
||||
CHECK_PYTORCH_REF --> mark_semantic_gap: pytorch_equivalent exists
|
||||
CHECK_PYTORCH_REF --> mark_blocked: no PyTorch reference
|
||||
mark_ready --> SELECT_OP: next op
|
||||
mark_semantic_gap --> SELECT_OP: next op
|
||||
mark_blocked --> SELECT_OP: next op
|
||||
SELECT_OP --> WRITE_REPORT: all ops classified
|
||||
WRITE_REPORT --> [*]
|
||||
```
|
||||
|
||||
Key gate: `pytorch_equivalent` determines autonomous vs human-required migration. `--check-op` confirms `ready` classification.
|
||||
|
||||
## Classification
|
||||
|
||||
| Classification | Condition | Downstream |
|
||||
| -------------- | ------------------------------------------------------------------ | ----------------------------------- |
|
||||
| `ready` | `--check-op` passes, no signature difference | Orchestrator flips status directly |
|
||||
| `semantic_gap` | Manifest-code difference + `pytorch_equivalent` exists | test-op → implement-op |
|
||||
| `blocked` | Difference but no PyTorch reference; or kernel-level change needed | Terminate. `reason` field explains. |
|
||||
|
||||
## Gap Report Format
|
||||
|
||||
Location: `.foundry/migrations/<family>.json`
|
||||
|
||||
Top-level:
|
||||
|
||||
```json
|
||||
{
|
||||
"family": "reduction",
|
||||
"audited_at": "2026-04-03T...",
|
||||
"total": 21,
|
||||
"summary": {"ready": 0, "semantic_gap": 21, "blocked": 0},
|
||||
"ops": { "<op_name>": { ... } }
|
||||
}
|
||||
```
|
||||
|
||||
Per-op entry. **Example — field set is not exhaustive. Add fields that aid the next step, omit those that don't.**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "spec-only",
|
||||
"source_op": "tileops/ops/reduction/softmax.py",
|
||||
"base_class": "_SoftmaxBaseOp",
|
||||
"classification": "semantic_gap",
|
||||
"missing_params": ["dim"],
|
||||
"manifest_signature": {
|
||||
"inputs": {"x": {"dtype": "float16 | bfloat16"}},
|
||||
"outputs": {"y": {"dtype": "same_as(x)"}},
|
||||
"params": {"dim": {"type": "int", "default": -1}},
|
||||
"shape_rules": ["y.shape == x.shape"]
|
||||
},
|
||||
"manifest_param_order": ["x", "dim"],
|
||||
"pytorch_equivalent": "torch.nn.functional.softmax",
|
||||
"notes": "dim hardcoded to -1"
|
||||
}
|
||||
```
|
||||
|
||||
Required fields: `classification`, `source_op`, `base_class`, `manifest_signature`. `base_class` is the immediate parent class name (e.g., `_SoftmaxBaseOp`); if the op inherits `Op` directly, use `"Op"`. The orchestrator uses `base_class` for GROUP_BY_BASE grouping. Gap report is a starting point — agent reads live code and manifest during downstream skills.
|
||||
|
||||
`pytorch_equivalent`: corresponding PyTorch function, or `null`. Not every op has one.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Load the merged manifest via `from tileops.manifest import load_manifest`, or read `tileops/manifest/<family>.yaml` directly when scoping to one family.
|
||||
1. Filter ops where `family == <arg>` and `status == spec-only`
|
||||
1. For each op:
|
||||
a. Read source file (`source_op`), find Op class, extract `__init__` and `forward` explicit named params
|
||||
b. Compare against `manifest_signature` (inputs, params)
|
||||
c. If no difference → run `python scripts/validate_manifest.py --check-op <name>` to confirm → `ready` or deeper gap
|
||||
d. If difference → determine `pytorch_equivalent`:
|
||||
- Strip `_fwd` suffix, match against `torch.nn.functional`, `torch`, `torch.special`, `torch.linalg` → `semantic_gap`
|
||||
- `_bwd` ops → always `blocked` (PyTorch backward is autograd-internal, no public reference function for autonomous testing)
|
||||
- No match → `blocked`
|
||||
1. Write gap report to `.foundry/migrations/<family>.json`
|
||||
1. Print summary table
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
---
|
||||
name: bench-op
|
||||
description: Fix benchmark file to work with the new Op interface. Run benchmark, fix errors, repeat until it produces numbers.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
`op_name`, `source_bench`, `source_op` — passed by align-family orchestrator.
|
||||
|
||||
## Contract
|
||||
|
||||
- **Input**: `op_name`, `source_bench`, `source_op`
|
||||
- **Output**: updated benchmark file + commit
|
||||
- **Termination (success)**: benchmark runs to completion, produces numeric output (latency/TFLOPS/bandwidth)
|
||||
- **Termination (blocked)**: failure is not interface-related (e.g., kernel bug). Return `blocked` with reason.
|
||||
- **Constraint**: must NOT modify Op implementation or tests. Benchmark-only changes.
|
||||
- **Environment**: local GPU required.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> RUN_BENCH
|
||||
RUN_BENCH --> DONE: benchmark produces numbers
|
||||
RUN_BENCH --> FIX_ERROR: runtime error
|
||||
FIX_ERROR --> RUN_BENCH: benchmark code fixed
|
||||
FIX_ERROR --> BLOCKED: error is not interface-related
|
||||
BLOCKED --> [*]: return blocked to orchestrator
|
||||
DONE --> COMMIT
|
||||
COMMIT --> [*]
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. RUN_BENCH
|
||||
|
||||
Execute the benchmark file:
|
||||
|
||||
```bash
|
||||
python -m pytest <source_bench> -v
|
||||
```
|
||||
|
||||
If it runs and produces numbers → DONE.
|
||||
|
||||
### 2. FIX_ERROR
|
||||
|
||||
Read the traceback. The error is the signal — typically the benchmark constructs the Op with the old interface (e.g., `Op(M, N, dtype)` instead of `Op(dim, dtype)`).
|
||||
|
||||
Fix the benchmark construction code to use the new Op interface. This is interactive — fix what breaks, don't predict errors in advance.
|
||||
|
||||
If the error is NOT about interface mismatch (e.g., kernel crash, CUDA error) → BLOCKED.
|
||||
|
||||
### 3. COMMIT
|
||||
|
||||
Commit benchmark changes only. Follow `docs/design/testing.md` benchmark requirements.
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
---
|
||||
name: fix-manifest
|
||||
description: Patch one missing structural field (kernel_map, static_dims) on an existing tileops/manifest/ entry. Auto-detects the field via the validator or takes `--field=<name>`. Reference-derivable fields (signature.*, shape_rules, dtype_combos, roofline) belong to add-manifest, not here.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
| Argument | Required | Description |
|
||||
| ---------------- | -------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `op_name` | Yes | One manifest key, or comma-separated list (e.g., `RMSNormFwdOp` or `SumFwdOp,MeanFwdOp,VarFwdOp`). |
|
||||
| `--field=<name>` | No | One of `kernel_map`, `static_dims`. Omit to auto-detect. |
|
||||
| `--dry-run` | No | Print diff and exit; no write, no PR. |
|
||||
|
||||
Multi-op: same `--field` applied to every op in the list. Multi-field is not supported — run again.
|
||||
|
||||
## Contract
|
||||
|
||||
- **MAY write** in `tileops/manifest/<family>.yaml` (the family file owning the entry): `source.kernel_map`, `signature.static_dims`. These two fields are derived from on-disk op / kernel evidence, not from the reference API. Use `ruamel.yaml` for round-trip preservation.
|
||||
- **MUST NOT write** anything else. Reference-derivable fields (`signature.{inputs,outputs,params,shape_rules,dtype_combos}`, `roofline.*`) belong to `add-manifest` — re-aligning those fields requires re-fetching the reference URL, which is `add-manifest`'s job. Other fields (`status`, `family`, `ref_api`, `workloads`, `source.{kernel,op,test,bench,bench_manifest_driven}`) are human-curated and not touched by either skill.
|
||||
- **MUST NOT** create new entries — use `add-manifest`.
|
||||
- **MUST NOT** flip `status` (that is `align-op@FLIP_STATUS`).
|
||||
- **MUST NOT** edit op / kernel / test / bench code.
|
||||
- **One field per invocation.**
|
||||
- **Termination**: every patched op's validator output is **monotonic** (no new error category vs. before the patch), or BLOCKED.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> PRE_CHECK
|
||||
PRE_CHECK --> [*]: entry missing → BLOCKED
|
||||
PRE_CHECK --> DIAGNOSE
|
||||
DIAGNOSE --> INFER: target in allowed list
|
||||
DIAGNOSE --> [*]: forbidden field → BLOCKED
|
||||
DIAGNOSE --> [*]: nothing to fix
|
||||
INFER --> PATCH
|
||||
INFER --> [*]: cannot infer → BLOCKED
|
||||
PATCH --> VALIDATE
|
||||
VALIDATE --> [*]: regression → revert and BLOCK
|
||||
VALIDATE --> CREATE_PR
|
||||
CREATE_PR --> [*]
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. PRE_CHECK
|
||||
|
||||
Resolve `op_name` in `tileops/manifest/`. Missing → BLOCKED: `op not in manifest; use add-manifest`.
|
||||
|
||||
### 2. DIAGNOSE
|
||||
|
||||
When `--field=` is provided: must be `kernel_map` or `static_dims`; else BLOCKED. Skip both checks below.
|
||||
|
||||
When `--field=` is omitted, run two checks in strict order:
|
||||
|
||||
**Check A — `kernel_map` presence.** If `source.kernel_map` is missing or empty → target = `kernel_map`, jump to INFER.
|
||||
|
||||
The validator only warns on missing `kernel_map` when `status == implemented`, so spec-only entries need this explicit check. Do NOT extend it to `static_dims` — `docs/design/manifest.md` (R7, R20) explicitly allows `static_dims` to be absent on fixed-rank ops; absence-only patching would manufacture changes for valid entries.
|
||||
|
||||
**Check B — validator output.** Run `python scripts/validate_manifest.py --check-op <op_name>`. Parse the first error:
|
||||
|
||||
- Field is `static_dims` → target = `static_dims`, jump to INFER.
|
||||
- Field is reference-derivable (`signature.{inputs,outputs,params,shape_rules,dtype_combos}`, `roofline.*`) → BLOCKED with redirect: `"<field> belongs to add-manifest; re-align this entry with /add-manifest <op_name> <ref_url>"`.
|
||||
- Other forbidden fields (`status`, `family`, etc.) → BLOCKED. Name the field, why it is out of scope, and what owns it.
|
||||
- No errors and Check A also empty → no-op; print `nothing to fix` and exit 0.
|
||||
|
||||
Write `.foundry/plan/<op_name>/fix-diagnosis.json`: `{op_name, target_field, validator_excerpt, action}`.
|
||||
|
||||
### 3. INFER
|
||||
|
||||
Build the patch payload from on-disk evidence. **Never guess** — if inference is impossible, BLOCKED with `evidence_needed: <what>`.
|
||||
|
||||
**`kernel_map`** — read the op file:
|
||||
|
||||
- T2 (L1-direct): copy `default_kernel_map()`'s return dict verbatim.
|
||||
- T1 (thin wrapper, see `docs/design/ops-design.md` § "Family-specific protocol variables"): family bases expose `default_kernel_map()` returning `{self._kernel_key: self._kernel_cls}`. Read it; substitute the subclass's `_kernel_key` / `_kernel_cls`.
|
||||
- Output format per `docs/design/manifest.md` § kernel_map: `{<dispatch_key>: <BareKernelClassName>}` — bare class name, NOT fully-qualified.
|
||||
|
||||
**`static_dims`** — `signature.inputs` shape names that the op binds at construction time (each entry in the op's `__init__` kwarg block, excluding `dtype` / `kernel_map` / `tune` / `signature.params` entries — see `docs/design/ops-design.md` § "Step 3"). Cross-check with `roofline.vars` if present.
|
||||
|
||||
### 4. PATCH
|
||||
|
||||
First capture the validator baseline — **before any file mutation**:
|
||||
|
||||
```bash
|
||||
python scripts/validate_manifest.py --check-op <op_name> > /tmp/fix-manifest-<op>-before.txt
|
||||
```
|
||||
|
||||
Do NOT use `git stash` for this — unsafe on a dirty tree (pulls in unrelated user changes).
|
||||
|
||||
Then insert each new key as a **sibling** of existing keys in its parent block, at this exact position (verifiable from any sibling entry):
|
||||
|
||||
| Field | YAML path | Position |
|
||||
| ------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `kernel_map` | `source.kernel_map` | between `source.kernel` and `source.op` |
|
||||
| `static_dims` | `signature.static_dims` | between `signature.params` and `signature.shape_rules`; if `params` absent, after `signature.outputs` |
|
||||
|
||||
Preserve adjacent comments. Do not reorder unrelated keys. If the existing entry deviates from the canonical layout, fall back to the order in `docs/design/manifest.md`.
|
||||
|
||||
### 5. VALIDATE
|
||||
|
||||
Capture after-baseline and diff:
|
||||
|
||||
```bash
|
||||
python scripts/validate_manifest.py --check-op <op_name> > /tmp/fix-manifest-<op>-after.txt
|
||||
diff /tmp/fix-manifest-<op>-before.txt /tmp/fix-manifest-<op>-after.txt
|
||||
```
|
||||
|
||||
Acceptable iff after's errors are a subset of before's (monotonic). Any new error → revert that op's patch and BLOCKED.
|
||||
|
||||
For multi-op runs: per-op independent. One op's regression reverts only that op's patch; siblings proceed.
|
||||
|
||||
Spec-only entries usually carry pre-existing errors (the reason they are spec-only — typically `[signature]` mismatches). Those are out of scope here — `align-op` closes them later.
|
||||
|
||||
### 6. CREATE_PR
|
||||
|
||||
If `--dry-run`, print diff and exit 0. Otherwise invoke `foundry:creating-pull-request`:
|
||||
|
||||
| Element | Single-op | Multi-op |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| title | `[Maintain][Manifest] fix <field> for <op_name>` (use `[Fix][Manifest]` if validator was actively rejecting) | `[Maintain][Manifest] add <field> for <family> spec-only ops` |
|
||||
| branch | `maintain/manifest/fix-<op-slug>-<field>` | `maintain/manifest/fix-<family>-<field>` |
|
||||
| body | which field, evidence, validator before/after, scope guard | per-op evidence table; per-op monotonic-check result; scope guard |
|
||||
|
||||
## Guardrails
|
||||
|
||||
- One field per invocation.
|
||||
- Never widen scope to a forbidden field — emit BLOCKED.
|
||||
- Never invent values; payload must trace to a file or to `ref_api`.
|
||||
- Never flip `status`.
|
||||
- Validator output ambiguous → STOP, ask user.
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
---
|
||||
name: follow-up
|
||||
description: Introspect a development session and generate follow-up issues for deferred work, discovered problems, and coverage gaps. Max 3 issues per invocation.
|
||||
---
|
||||
|
||||
## Args
|
||||
|
||||
| Argument | Description |
|
||||
| -------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `<PR_NUMBER>` | Required. TileOPs PR number. |
|
||||
| `--nightshift` | Skip the interactive presentation; auto-accept all candidates; inject `nightshift` label. |
|
||||
|
||||
## Contract
|
||||
|
||||
Input: PR ref + conversation (if available). Output: ≤3 follow-up issues, in-scope fixes committed, out-of-scope suggestions printed to stdout. **Never edit the PR body** — the review skill owns it.
|
||||
|
||||
## Modes
|
||||
|
||||
- **Session-rich**: introspection is primary signal; PR supplements.
|
||||
- **Session-poor**: PR diff + human reviewer comments only.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Resolve PR
|
||||
|
||||
```bash
|
||||
NIGHTSHIFT=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--nightshift) NIGHTSHIFT=1 ;;
|
||||
-*) echo "Unknown flag: $arg" >&2; exit 1 ;;
|
||||
*) PR_NUMBER="${PR_NUMBER:-$arg}" ;;
|
||||
esac
|
||||
done
|
||||
[[ -z "${PR_NUMBER:-}" ]] && { echo "Usage: /follow-up <PR_NUMBER>" >&2; exit 1; }
|
||||
|
||||
gh pr view "$PR_NUMBER" --json number,title,url,body
|
||||
OWNER_REPO=$(gh repo view --json nameWithOwner -q '.nameWithOwner')
|
||||
```
|
||||
|
||||
PR not found → terminate.
|
||||
|
||||
### 2. Collect
|
||||
|
||||
| Source | How |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Diff | `gh pr diff "$PR_NUMBER"` |
|
||||
| Session | Scan for deferrals, workarounds, blocked items |
|
||||
| In-code markers | Grep changed files for `TODO`, `FIXME`, `HACK`, `XXX` |
|
||||
| Reviewer comments | Both endpoints below, filtered to non-author non-bot |
|
||||
|
||||
```bash
|
||||
export PR_AUTHOR=$(gh pr view "$PR_NUMBER" --json author -q '.author.login')
|
||||
FILTER='[.[] | select(.user.login != env.PR_AUTHOR and .user.type != "Bot"
|
||||
and (.user.login | test("copilot|gemini|github-actions"; "i") | not))]'
|
||||
gh api "repos/$OWNER_REPO/pulls/$PR_NUMBER/comments" --paginate --jq "$FILTER"
|
||||
gh api "repos/$OWNER_REPO/issues/$PR_NUMBER/comments" --paginate --jq "$FILTER"
|
||||
```
|
||||
|
||||
### 3. Classify
|
||||
|
||||
**Issue-worthy** (→ follow-up issue):
|
||||
|
||||
| Category | Signal |
|
||||
| ---------------- | ------------------------------------------------------ |
|
||||
| Scope deferral | "not in this PR", explicit defer |
|
||||
| Fragile coupling | Workarounds, monkey-patches |
|
||||
| Coverage gap | Untested cases, missing edge cases, skipped benchmarks |
|
||||
| Consistency gap | Doc drift; same problem in other modules |
|
||||
|
||||
**Suggestion** (no issue):
|
||||
|
||||
| Tier | Signal | Action |
|
||||
| ------------ | ----------------------------------------------- | ------ |
|
||||
| In-scope | Touches only this PR's files; small, mechanical | Step 7 |
|
||||
| Out-of-scope | Outside diff, expands behavior, or subjective | Step 8 |
|
||||
|
||||
Uncertain → out-of-scope. Nothing in either bucket → step 8 (empty report). Do not manufacture follow-ups.
|
||||
|
||||
### 4. Merge → max 3 issues
|
||||
|
||||
Same module / root cause → merge. Different + independent → keep separate.
|
||||
|
||||
### 5. Present
|
||||
|
||||
`--nightshift`: skip; auto-accept; → step 6.
|
||||
|
||||
Default — render in dependency order, wait on `Actions:`:
|
||||
|
||||
```
|
||||
Follow-up candidates from PR #<N>: <title>
|
||||
1. [TYPE][SCOPE] <title> Category: <…> | <1–2 sentences>
|
||||
2. [TYPE][SCOPE] <title> ← parallel with #1
|
||||
3. [TYPE][SCOPE] <title> ← depends on #1
|
||||
|
||||
Execution order: {#1, #2} → #3
|
||||
|
||||
In-scope fixes:
|
||||
- <file:line> — <nit>
|
||||
Out-of-scope suggestions:
|
||||
- <nit>
|
||||
|
||||
Actions: confirm all / drop by N / edit / move <item> to out-of-scope
|
||||
```
|
||||
|
||||
### 6. Create — delegate to `creating-issue`
|
||||
|
||||
For each confirmed item: write a draft to a tmpfile, then invoke `foundry:creating-issue --from-draft <tmpfile>`. **Never `gh issue create` directly** — that bypasses the 5-section HARD GATE which `foundry:pipeline` Phase A re-validates downstream.
|
||||
|
||||
Draft body must conform to the canonical template at `foundry/skills/creating-issue/SKILL.md` Step 4 — `creating-issue` is the single owner; do not duplicate or paraphrase its section names here.
|
||||
|
||||
Frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: <FEAT|BUG|PERF|REFACTOR|DOCS|TEST>
|
||||
component: <affected module>
|
||||
labels: [follow-up] # add `nightshift` only when --nightshift was passed
|
||||
target_repo: <OWNER_REPO>
|
||||
---
|
||||
```
|
||||
|
||||
Labels (the `nightshift` label is human-curated — fail fast, do not auto-create):
|
||||
|
||||
```bash
|
||||
gh label list --search follow-up --json name --jq '.[].name' | grep -qx follow-up \
|
||||
|| gh label create follow-up --color c5def5 --description "Generated from dev session introspection"
|
||||
|
||||
if [[ "$NIGHTSHIFT" == "1" ]]; then
|
||||
gh label list --search nightshift --json name --jq '.[].name' | grep -qx nightshift \
|
||||
|| { echo "nightshift label missing in $OWNER_REPO" >&2; exit 1; }
|
||||
fi
|
||||
```
|
||||
|
||||
### 7. Apply in-scope fixes
|
||||
|
||||
For each: verify file is in `gh pr diff --name-only "$PR_NUMBER"` (else demote); Edit; run a fast check (`pre-commit run --files <paths>` or module-scoped unit tests). Commit the batch:
|
||||
|
||||
```
|
||||
[Chore] apply in-scope follow-up suggestions from PR #$PR_NUMBER review
|
||||
|
||||
- <file:line> — <what changed>
|
||||
```
|
||||
|
||||
Fix fails its check or adds unrelated diff → `git restore`, demote. Never force-push or amend.
|
||||
|
||||
### 8. Report
|
||||
|
||||
Stdout only. Omit empty sections.
|
||||
|
||||
```
|
||||
PR #<PR_NUMBER> follow-up complete.
|
||||
|
||||
Issues created:
|
||||
- #<N> — <summary>
|
||||
|
||||
Applied (commit <sha>):
|
||||
- <file:line> — <summary>
|
||||
|
||||
Out-of-scope suggestions:
|
||||
- <nit>
|
||||
|
||||
Execution order: {#A, #B} → #C
|
||||
```
|
||||
|
||||
Nothing in any bucket: `PR #<PR_NUMBER>: no follow-up issues or suggestions.`
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
---
|
||||
name: implement-op
|
||||
description: Modify op code to match the manifest-declared interface, making spec tests pass.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
`op_name`, `manifest_signature`, `source_op`, `source_test` — passed by align-family orchestrator.
|
||||
|
||||
## Contract
|
||||
|
||||
- **Input**: `op_name`, `manifest_signature`, `source_op`, `source_test`
|
||||
- **Output**: modified op code + commit + `observations` list (returned to orchestrator)
|
||||
- **Termination (success)**: `python scripts/validate_manifest.py --check-op <name>` all levels pass + new tests pass.
|
||||
- **Termination (blocked)**: fix requires changes beyond Op layer. Return `blocked` with reason.
|
||||
- **Constraint**: must NOT modify `tileops/manifest/`. Must NOT modify tests written by `test-op` in this align-op run (spec contract). MAY update pre-existing tests in `<source_test>` whose call-sites use the legacy API. The parent orchestrator (`align-op`) handles the manifest flip at FLIP_STATUS, bound by the [Status flip carve-out](../../rules/manifest-trust-model.md#status-flip-carve-out). If your implementation needs a contractual-field change to make tests pass, return BLOCKED — do not edit the manifest yourself.
|
||||
- **Behavioral compatibility**: default param values (from manifest) must produce identical results to the old implementation. The old API shape (e.g., `__init__(M, N)`) is NOT preserved — the manifest defines the target interface.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> ANALYZE
|
||||
ANALYZE --> DIAGNOSE: semantic knowledge extracted
|
||||
DIAGNOSE --> VALIDATE: gap already resolved (base class fixed by previous op)
|
||||
DIAGNOSE --> IMPLEMENT: gap exists
|
||||
IMPLEMENT --> VALIDATE: sub-step completed
|
||||
VALIDATE --> IMPLEMENT: sub-step failed
|
||||
VALIDATE --> MARK_DONE: all pass
|
||||
VALIDATE --> BLOCKED: fix exceeds Op layer scope
|
||||
MARK_DONE --> COMMIT
|
||||
COMMIT --> [*]
|
||||
BLOCKED --> [*]: return blocked to orchestrator
|
||||
```
|
||||
|
||||
## Dual-path policy
|
||||
|
||||
Before refactoring a base class, count its subclasses:
|
||||
|
||||
```bash
|
||||
grep -rlE "class\s+[A-Z][A-Za-z0-9]*\s*\(\s*<BaseName>\s*\)" tileops/ops/
|
||||
```
|
||||
|
||||
- **One subclass** (the op being migrated): refactor the base in place. No dual-path.
|
||||
- **Multiple subclasses**: keep the legacy `__init__` path alongside the new one so unmigrated siblings still pass. Cleanup gate removes the legacy path after all siblings migrate.
|
||||
|
||||
Do NOT preemptively migrate siblings to avoid dual-path — that violates per-op scope.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. ANALYZE
|
||||
|
||||
Read existing code (`source_op`) to extract semantic knowledge:
|
||||
|
||||
- What the computation does (the algorithm)
|
||||
- Where constraints are hardcoded (e.g., `dim=-1`, `reshape(-1, N)`)
|
||||
- What the generalization path is
|
||||
|
||||
Manifest = WHAT (target interface). Existing code = HOW (computation logic). The delta is the work.
|
||||
|
||||
This analysis is internal — not persisted.
|
||||
|
||||
### 2. DIAGNOSE
|
||||
|
||||
Check if the gap still exists. A previous op's migration may have fixed the shared base class.
|
||||
|
||||
- Gap resolved → skip to VALIDATE
|
||||
- Gap exists → proceed to IMPLEMENT
|
||||
|
||||
### 3. IMPLEMENT
|
||||
|
||||
Decompose into independently verifiable sub-steps. Each sub-step either succeeds or fails with precise location (→ BLOCKED). No retry loops — if a sub-step fails with the same error twice, the task is beyond current scope.
|
||||
|
||||
Agent determines sub-steps based on the specific gap. Do not follow a fixed recipe.
|
||||
|
||||
### 4. VALIDATE
|
||||
|
||||
Run all checks:
|
||||
|
||||
```bash
|
||||
python scripts/validate_manifest.py --check-op <op_name>
|
||||
python -m pytest <source_test> -v
|
||||
```
|
||||
|
||||
All must pass. If not, return to IMPLEMENT.
|
||||
|
||||
### 5. MARK_DONE
|
||||
|
||||
Record `observations` — design knowledge discovered during migration:
|
||||
|
||||
- Patterns (e.g., "scan ops need transpose, reduction ops reshape")
|
||||
- Edge cases
|
||||
- Abstraction opportunities
|
||||
|
||||
Do NOT modify manifest or design docs. Observations are returned to orchestrator and surfaced in PR for human review.
|
||||
|
||||
### 6. COMMIT
|
||||
|
||||
Commit code changes only. Do not commit manifest changes.
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
---
|
||||
name: resolve-tileops
|
||||
description: Per-round driver of stateful agent-driven review-resolution on a tile-ai/TileOPs PR (developer side). Designed for /loop dynamic mode — re-fires until a terminal action. Outer caller must run preflight.sh once before starting the loop. State persists in `.foundry/runs/{issue-<N> | pr-<PR>}/resolve/`.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
| Argument | Required | Description |
|
||||
| ------------- | -------- | -------------------------------- |
|
||||
| `<PR_NUMBER>` | Yes | TileOPs PR number (e.g. `1133`). |
|
||||
|
||||
Resolution work runs in **this agent session** — no external subagent.
|
||||
|
||||
## Step 1: Pre-round
|
||||
|
||||
```bash
|
||||
PR=$ARGUMENTS
|
||||
PRE=$(bash .claude/skills/resolve-tileops/round-pre.sh "$PR") || exit 1
|
||||
ACTION=$(echo "$PRE" | jq -r .action)
|
||||
ROUND=$(echo "$PRE" | jq -r .round)
|
||||
RUN_DIR=$(echo "$PRE" | jq -r .run_dir)
|
||||
SNAP=$(echo "$PRE" | jq -r .snap_prefix)
|
||||
MESSAGE=$(echo "$PRE" | jq -r .message)
|
||||
```
|
||||
|
||||
Branch on `ACTION`:
|
||||
|
||||
- `terminate-success` / `terminate-diverged` / `terminate-external` /
|
||||
`terminate-stalled` — write retrospective (see below), print
|
||||
`MESSAGE`, **return without ScheduleWakeup**.
|
||||
- `idle` — print `MESSAGE`, ScheduleWakeup with `delaySeconds=180`,
|
||||
`prompt=/resolve-tileops <PR>`, `reason="PR #<PR> idle — polling"`. Return.
|
||||
- `continue` — proceed to Step 2.
|
||||
|
||||
### Retrospective (terminal actions only)
|
||||
|
||||
Write a terse retrospective directly to `$RUN_DIR/retrospective.md` based
|
||||
on this session's understanding of what happened across the loop's rounds.
|
||||
|
||||
Required:
|
||||
|
||||
- **Problem** (1–2 lines on core reviewer concerns)
|
||||
- **Resolution** (`all-addressed` | `partial` | `unresolved`, one line)
|
||||
|
||||
Optional (only if substantive):
|
||||
|
||||
- **Approach** (1–2 lines on techniques applied)
|
||||
- **Follow-up** (concrete deferred items, one per line)
|
||||
|
||||
Action-oriented; no long prose.
|
||||
|
||||
## Step 2: Load context
|
||||
|
||||
**Round 1 only** — read into your working context:
|
||||
|
||||
- `.claude/skills/resolve-tileops/procedure.md` — triage / fix / reply / resolve threads.
|
||||
- `.claude/skills/resolve-tileops/criteria.md` — reply formats and hard rules.
|
||||
|
||||
Round 2+ rely on session memory.
|
||||
|
||||
**Every round** — read this round's inputs:
|
||||
|
||||
- `$RUN_DIR/inbox-history/round-NN.md` — this round's inbox guidance,
|
||||
if any (only present if `inbox.md` was non-empty when round-pre archived it).
|
||||
- `$SNAP.new-reviews.json`
|
||||
- `$SNAP.new-inline-comments.json`
|
||||
- `$SNAP.unresolved-threads.json`
|
||||
- `$SNAP.ci.json`
|
||||
- `$SNAP.auto-resolve.json` — JSON action plan from the stale-bot auto-resolver. `resolve[]` lists threads the classifier *planned* to auto-reply + resolve (known bot, anchored to a stale commit, no non-whitelisted participant anywhere in the thread); per-thread outcomes live under `executed.{resolved, reply_failed, resolve_failed}` so consumers can distinguish a planned-and-completed resolve from a planned-but-failed one. `unknown_bot_like[]` and `$RUN_DIR/round-NN.unknown-bot-like.json` flag bot-like logins missing from `known-bots.json` for human triage. `skip[]` lists threads the resolver intentionally left untouched (humans, mixed-author threads, or bots already at HEAD).
|
||||
|
||||
## Step 3: Resolve
|
||||
|
||||
Apply `procedure.md`. Inbox guidance for this round (if any) overrides
|
||||
default behavior where they conflict.
|
||||
|
||||
Do NOT post a structured trailer in any PR comment — `round-post.sh`
|
||||
detects round effects from GitHub state.
|
||||
|
||||
## Step 4: Post-round
|
||||
|
||||
```bash
|
||||
POST=$(bash .claude/skills/resolve-tileops/round-post.sh "$PR") || exit 1
|
||||
PUSHED_SHA=$(echo "$POST" | jq -r .pushed_sha)
|
||||
THREADS_RESOLVED=$(echo "$POST" | jq -r .threads_resolved)
|
||||
OPEN_AFTER=$(echo "$POST" | jq -r .open_after)
|
||||
```
|
||||
|
||||
Print: `Round $ROUND done — pushed=$PUSHED_SHA, threads_resolved=$THREADS_RESOLVED, open_after=$OPEN_AFTER.`
|
||||
|
||||
## Step 5: Self-schedule (only under /loop)
|
||||
|
||||
After a real work round, ScheduleWakeup:
|
||||
|
||||
- `prompt`: `/resolve-tileops <PR>`
|
||||
- `delaySeconds`: `180`
|
||||
- `reason`: `"PR #<PR> resolve round <ROUND> — pushed=<PUSHED_SHA>"`
|
||||
|
||||
Outside `/loop`: skip; just return.
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# auto-resolve-stale.sh — classify unresolved review threads for the
|
||||
# stale-bot auto-resolution policy and (in normal mode) execute the
|
||||
# GraphQL mutations that auto-reply + resolve qualifying threads.
|
||||
#
|
||||
# Inputs:
|
||||
# --threads <file> JSON {head_sha, threads:[{id, comments:{nodes:[
|
||||
# {id, databaseId, author:{login}, commit:{oid},
|
||||
# body, path, line}]}}]}
|
||||
# --bots <file> known-bots.json
|
||||
# --run-dir <dir> where to drop unknown-bot-like artifact
|
||||
# --round <NN> zero-padded round number for artifact filename
|
||||
# --dry-run do not call the GraphQL API; just emit the action plan
|
||||
#
|
||||
# Stdout: JSON {resolve:[{thread_id,comment_id,login,reply}],
|
||||
# unknown_bot_like:[{thread_id,login}],
|
||||
# skip:[{thread_id,reason}]}
|
||||
#
|
||||
# Side effect: $RUN_DIR/round-<NN>.unknown-bot-like.json (only when
|
||||
# at least one unknown bot-like thread was seen this call).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPLY_TEXT="Not assessed on latest HEAD"
|
||||
|
||||
THREADS_FILE=""; BOTS_FILE=""; RUN_DIR=""; ROUND=""; DRY_RUN=0
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--threads) THREADS_FILE="$2"; shift 2 ;;
|
||||
--bots) BOTS_FILE="$2"; shift 2 ;;
|
||||
--run-dir) RUN_DIR="$2"; shift 2 ;;
|
||||
--round) ROUND="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
*) echo "auto-resolve-stale: unknown arg $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[[ -f "$THREADS_FILE" ]] || { echo "auto-resolve-stale: missing --threads" >&2; exit 2; }
|
||||
[[ -f "$BOTS_FILE" ]] || { echo "auto-resolve-stale: missing --bots" >&2; exit 2; }
|
||||
[[ -n "$RUN_DIR" ]] || { echo "auto-resolve-stale: missing --run-dir" >&2; exit 2; }
|
||||
[[ "$ROUND" =~ ^[0-9]{2}$ ]] || { echo "auto-resolve-stale: --round must be zero-padded 2-digit" >&2; exit 2; }
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { echo "auto-resolve-stale: missing jq" >&2; exit 2; }
|
||||
|
||||
# Build the action plan in pure jq — single pass, no shell-side per-thread state.
|
||||
# REPLY_TEXT is passed in as --arg so the action plan and the executed
|
||||
# mutation body below share a single source of truth.
|
||||
# Validate the unknown-bot-like policy up front so the config field is
|
||||
# load-bearing rather than decorative. Only "log_for_manual_triage" is
|
||||
# implemented today — any other value fails fast.
|
||||
POLICY=$(jq -r '.policy.unknown_bot_like_login // ""' "$BOTS_FILE")
|
||||
if [[ "$POLICY" != "log_for_manual_triage" ]]; then
|
||||
echo "auto-resolve-stale: unsupported policy.unknown_bot_like_login='$POLICY' (expected 'log_for_manual_triage')" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PLAN=$(jq --slurpfile bots "$BOTS_FILE" --arg reply "$REPLY_TEXT" '
|
||||
# Normalise both sides: strip a trailing "[bot]" suffix before comparing.
|
||||
# GitHub returns either "copilot-pull-request-reviewer" or
|
||||
# "copilot-pull-request-reviewer[bot]" depending on the API; treat them
|
||||
# as the same identity for the whitelist check.
|
||||
def strip_bot: sub("\\[bot\\]$"; "");
|
||||
def is_known($known; $login):
|
||||
($known | map(strip_bot) | index($login | strip_bot)) != null;
|
||||
# Bot-like pattern: unknown identities that look like a GitHub App.
|
||||
# Only the literal "[bot]" suffix counts — that suffix is reserved by
|
||||
# GitHub for GitHub Apps and cannot appear in a human username. Any
|
||||
# login ending in "[bot]" that is not on the whitelist is bucketed as
|
||||
# an unknown bot-like identity for human triage, regardless of the
|
||||
# prefix. A bare "-reviewer" / "-bot" without the suffix is a regular
|
||||
# user account (e.g. "alice-reviewer") and stays in the human bucket.
|
||||
def is_bot_like($login):
|
||||
$login | test("\\[bot\\]$");
|
||||
. as $in
|
||||
| ($bots[0].review_bot_logins // []) as $known
|
||||
| ($in.head_sha) as $head
|
||||
| [ $in.threads[]
|
||||
| . as $t
|
||||
| ($t.comments.nodes[0]) as $first
|
||||
| ($first.author.login // "") as $login
|
||||
| ($first.commit.oid // "") as $oid
|
||||
# Whole-thread author check. Collect every non-whitelisted login
|
||||
# found anywhere in the thread, then split it into:
|
||||
# - human_repliers: login that is not bot-like (no [bot] suffix)
|
||||
# - unknown_bot_repliers: login that is bot-like but not on the
|
||||
# whitelist (e.g. a previously-unseen GitHub App)
|
||||
# ANY non-whitelisted participant — human OR unknown bot — must
|
||||
# disqualify the thread from auto-resolve. Recording them in
|
||||
# unknown_bot_like surfaces them for human triage as the PR
|
||||
# contract requires; resolving the thread anyway would silently
|
||||
# swallow that signal.
|
||||
| [ $t.comments.nodes[]
|
||||
| (.author.login // "")
|
||||
| select(. != "" and (is_known($known; .) | not))
|
||||
] as $unknown_logins
|
||||
| ($unknown_logins | map(select(is_bot_like(.) | not)) | length) as $human_repliers
|
||||
| ($unknown_logins | map(select(is_bot_like(.)))) as $unknown_bot_logins
|
||||
| {
|
||||
thread_id: $t.id,
|
||||
comment_id: ($first.id // ""),
|
||||
login: $login,
|
||||
oid: $oid,
|
||||
known_bot: is_known($known; $login),
|
||||
bot_like: is_bot_like($login),
|
||||
# Distinct buckets:
|
||||
# missing_oid: comment has no commit anchor → cannot judge stale
|
||||
# stale: oid present and != head
|
||||
# at_head: oid present and == head
|
||||
missing_oid: ($oid == ""),
|
||||
stale: ($oid != $head and $oid != ""),
|
||||
mixed: (($human_repliers > 0) or (($unknown_bot_logins | length) > 0)),
|
||||
unknown_bot_logins: ($unknown_bot_logins | unique)
|
||||
}
|
||||
] as $rows
|
||||
| {
|
||||
resolve: [
|
||||
$rows[] | select(.known_bot and .stale and (.mixed | not))
|
||||
| { thread_id, comment_id, login, reply: $reply }
|
||||
],
|
||||
# A thread enters unknown_bot_like if its root is a bot-like
|
||||
# unknown identity OR if any non-root comment introduces an
|
||||
# unknown bot-like login. Each (thread, login) pair is emitted
|
||||
# once so the human-triage artifact captures every unrecognized
|
||||
# GitHub App that touched the thread.
|
||||
unknown_bot_like: [
|
||||
$rows[]
|
||||
| . as $r
|
||||
| (
|
||||
([ if ($r.bot_like and ($r.known_bot|not)) then $r.login else empty end ]
|
||||
+ $r.unknown_bot_logins)
|
||||
| unique
|
||||
| .[]
|
||||
| { thread_id: $r.thread_id, login: . }
|
||||
)
|
||||
],
|
||||
skip: [
|
||||
$rows[]
|
||||
| select(
|
||||
.mixed # any non-whitelisted participant in thread
|
||||
or ((.known_bot|not) and (.bot_like|not)) # human-rooted (no other comments)
|
||||
or (.known_bot and (.stale|not)) # bot not stale (at HEAD or missing oid)
|
||||
)
|
||||
| {
|
||||
thread_id,
|
||||
reason: (
|
||||
if .mixed and .known_bot then "mixed_thread_known_bot_root"
|
||||
elif .mixed and .bot_like then "mixed_thread_unknown_bot_like_root"
|
||||
elif .mixed then "mixed_thread_human_root"
|
||||
elif .known_bot and .missing_oid then "known_bot_missing_commit_oid"
|
||||
elif .known_bot and (.stale|not) then "known_bot_at_head"
|
||||
elif (.bot_like|not) then "human_reviewer"
|
||||
else "other"
|
||||
end
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
' "$THREADS_FILE")
|
||||
|
||||
if (( DRY_RUN )); then
|
||||
# Dry-run is a pure read: no GraphQL mutations AND no on-disk side
|
||||
# effects. The artifact write below belongs only to the live path so
|
||||
# tests / future tooling can inspect the action plan without leaving
|
||||
# a stale unknown-bot-like artifact behind.
|
||||
printf '%s\n' "$PLAN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Drop the artifact for human triage when there is anything to record.
|
||||
ARTIFACT="$RUN_DIR/round-${ROUND}.unknown-bot-like.json"
|
||||
UNKNOWN_COUNT=$(printf '%s' "$PLAN" | jq '.unknown_bot_like | length')
|
||||
if (( UNKNOWN_COUNT > 0 )); then
|
||||
mkdir -p "$RUN_DIR"
|
||||
printf '%s' "$PLAN" | jq '.unknown_bot_like' > "$ARTIFACT"
|
||||
fi
|
||||
|
||||
# Execute mutations for each resolve entry: post a reply on the first
|
||||
# comment, then — only if the reply succeeded — mark the thread resolved.
|
||||
# Skipping the resolve when the reply fails preserves the contract that
|
||||
# every auto-resolved thread carries the neutral reply, so a thread we
|
||||
# could not reply to is left unresolved for the next round / human
|
||||
# triage. Per-thread failures are logged but do not abort the loop, so
|
||||
# one transient API hiccup doesn't strand the rest of the batch.
|
||||
#
|
||||
# GH_BIN allows the test harness to inject a mock gh binary that
|
||||
# simulates reply / resolve failures without touching the network.
|
||||
GH_BIN="${GH_BIN:-gh}"
|
||||
command -v "$GH_BIN" >/dev/null 2>&1 || { echo "auto-resolve-stale: missing $GH_BIN" >&2; exit 2; }
|
||||
RESOLVE_COUNT=$(printf '%s' "$PLAN" | jq '.resolve | length')
|
||||
RESOLVED_IDS=()
|
||||
REPLY_FAILED_IDS=()
|
||||
RESOLVE_FAILED_IDS=()
|
||||
if (( RESOLVE_COUNT > 0 )); then
|
||||
while IFS= read -r tid; do
|
||||
[[ -n "$tid" ]] || continue
|
||||
if "$GH_BIN" api graphql -f query='
|
||||
mutation($tid:ID!,$body:String!){
|
||||
addPullRequestReviewThreadReply(input:{
|
||||
pullRequestReviewThreadId:$tid, body:$body
|
||||
}){ comment{ id } }
|
||||
}' -F tid="$tid" -F body="$REPLY_TEXT" >/dev/null; then
|
||||
if "$GH_BIN" api graphql -f query='
|
||||
mutation($tid:ID!){
|
||||
resolveReviewThread(input:{threadId:$tid}){ thread{ id isResolved } }
|
||||
}' -F tid="$tid" >/dev/null; then
|
||||
RESOLVED_IDS+=("$tid")
|
||||
else
|
||||
echo "auto-resolve-stale: resolve failed for $tid" >&2
|
||||
RESOLVE_FAILED_IDS+=("$tid")
|
||||
fi
|
||||
else
|
||||
echo "auto-resolve-stale: reply failed for $tid; leaving thread unresolved" >&2
|
||||
REPLY_FAILED_IDS+=("$tid")
|
||||
fi
|
||||
done < <(printf '%s' "$PLAN" | jq -r '.resolve[].thread_id')
|
||||
fi
|
||||
|
||||
# Annotate the plan with execution outcomes so the caller (and the test
|
||||
# harness) can distinguish "resolved" from "reply failed, left open".
|
||||
# Use `jq -n '$ARGS.positional' --args …` to lift each shell array into
|
||||
# a JSON string array in one shot — the `${arr[@]+"${arr[@]}"}` guard is
|
||||
# needed under `set -u` so an empty array does not trip "unbound
|
||||
# variable".
|
||||
printf '%s' "$PLAN" | jq \
|
||||
--argjson resolved "$(jq -n '$ARGS.positional' --args ${RESOLVED_IDS[@]+"${RESOLVED_IDS[@]}"})" \
|
||||
--argjson reply_failed "$(jq -n '$ARGS.positional' --args ${REPLY_FAILED_IDS[@]+"${REPLY_FAILED_IDS[@]}"})" \
|
||||
--argjson resolve_failed "$(jq -n '$ARGS.positional' --args ${RESOLVE_FAILED_IDS[@]+"${RESOLVE_FAILED_IDS[@]}"})" \
|
||||
'. + {executed: {resolved: $resolved, reply_failed: $reply_failed, resolve_failed: $resolve_failed}}'
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
### 1. Inline reply format
|
||||
|
||||
- **Accept**: `Adopted. <what was fixed>. See <short_sha>.`
|
||||
- **Reject**: `<conclusion>. <evidence or reasoning>.`
|
||||
- **Defer**: `Valid point. Out of scope for this PR — <reason>. Will address in follow-up.`
|
||||
|
||||
One reply per thread. Resolve every thread that was replied to, regardless of verdict.
|
||||
|
||||
### 2. Top-level note (optional, only when something cross-cutting can't fit inline)
|
||||
|
||||
```
|
||||
Addressed in <short_sha>. See thread replies for specifics.
|
||||
|
||||
### Cross-cutting (optional)
|
||||
- <one line per pattern handled jointly across threads>
|
||||
|
||||
### Deferred (optional)
|
||||
- <one line per item out of scope, with reason>
|
||||
```
|
||||
|
||||
### 3. Hard rules
|
||||
|
||||
- Do NOT restate inline replies. If a thread already has the reply, the top-level note must not repeat it.
|
||||
- No per-file / per-line bullets. Those live inline.
|
||||
- No GitHub review IDs (`#4181055953`) — meaningless to humans.
|
||||
- One short paragraph + at most two markdown sections in any top-level note. If you can't fit it, rethink whether it belongs inline.
|
||||
- All replies in English; concise — conclusion, action, reasoning. No filler.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"review_bot_logins": [
|
||||
"copilot-pull-request-reviewer",
|
||||
"Copilot",
|
||||
"gemini-code-assist",
|
||||
"github-actions"
|
||||
],
|
||||
"policy": {
|
||||
"unknown_bot_like_login": "log_for_manual_triage"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# preflight.sh <PR_NUMBER>
|
||||
#
|
||||
# Validates env and initializes per-PR resolve state. Idempotent: round 1
|
||||
# does cold init (env checks, TASK_ROOT resolution from PR body, mkdir,
|
||||
# meta.json), round 2+ scans existing state and returns it without rework.
|
||||
# The skill body assumes preflight has succeeded.
|
||||
#
|
||||
# Stdout: absolute path to the run dir's meta.json (single line).
|
||||
# Stderr: human-readable status / errors.
|
||||
# Exit 0: state ready. Non-zero: env or arg error.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PR="${1:?usage: preflight.sh <PR_NUMBER>}"
|
||||
[[ "$PR" =~ ^[0-9]+$ ]] || { echo "preflight: PR must be a positive integer" >&2; exit 1; }
|
||||
|
||||
REPO="tile-ai/TileOPs"
|
||||
# Dependency checks first — both branches below use jq/gh.
|
||||
command -v gh >/dev/null 2>&1 || { echo "preflight: missing gh" >&2; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "preflight: missing jq" >&2; exit 1; }
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Anchor state to the main checkout's `.foundry/runs/`, not cwd. When
|
||||
# invoked from a linked worktree (e.g. via foundry pipeline HANDOFF),
|
||||
# `--show-toplevel` returns the worktree path; using it would scatter
|
||||
# resolve state across worktrees. `git rev-parse --git-common-dir`
|
||||
# returns the main worktree's `.git` directory regardless of which
|
||||
# worktree we run from; its parent is the main checkout. Single
|
||||
# command, no pipe — avoids SIGPIPE under `set -o pipefail`.
|
||||
GIT_COMMON_DIR="$(git -C "$SKILL_DIR" rev-parse --git-common-dir 2>/dev/null)" \
|
||||
|| { echo "preflight: cannot resolve repo root from \$SKILL_DIR=$SKILL_DIR" >&2; exit 1; }
|
||||
# --git-common-dir is relative to the invoking worktree when that
|
||||
# worktree is the main one, absolute when invoked from a linked
|
||||
# worktree. Normalize to absolute, then take parent.
|
||||
[[ "$GIT_COMMON_DIR" != /* ]] && GIT_COMMON_DIR="$SKILL_DIR/$GIT_COMMON_DIR"
|
||||
REPO_PATH="$(cd "$GIT_COMMON_DIR/.." && pwd)"
|
||||
|
||||
# Round 2+ fast path: an existing run dir's meta.json already pins this PR.
|
||||
META=""
|
||||
for m in "$REPO_PATH/.foundry/runs"/*/resolve/meta.json; do
|
||||
[[ -f "$m" ]] || continue
|
||||
if [[ "$(jq -r '.pr_number' "$m" 2>/dev/null)" = "$PR" ]]; then
|
||||
META="$m"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$META" ]]; then
|
||||
echo "preflight: state exists for PR #$PR at $META" >&2
|
||||
echo "$META"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Round 1 cold start: validate repo remote, resolve TASK_ROOT, create state.
|
||||
git -C "$REPO_PATH" remote -v \
|
||||
| awk '/tile-ai\/TileOPs(\.git)?[[:space:]]+\(fetch\)/ {found=1; exit} END{exit !found}' \
|
||||
|| { echo "preflight: no git remote in $REPO_PATH points to tile-ai/TileOPs" >&2; exit 1; }
|
||||
|
||||
PR_BODY=$(gh pr view "$PR" --repo "$REPO" --json body --jq .body) \
|
||||
|| { echo "preflight: gh pr view failed for PR #$PR (auth? missing?)" >&2; exit 1; }
|
||||
ISSUE=$(printf '%s' "$PR_BODY" \
|
||||
| grep -oiE '(Closes|Fixes|Resolves)[[:space:]]+#[0-9]+' \
|
||||
| head -1 \
|
||||
| grep -oE '[0-9]+' \
|
||||
|| true)
|
||||
if [[ -n "$ISSUE" ]]; then
|
||||
TASK_ROOT="$REPO_PATH/.foundry/runs/issue-$ISSUE"
|
||||
else
|
||||
TASK_ROOT="$REPO_PATH/.foundry/runs/pr-$PR"
|
||||
fi
|
||||
RUN_DIR="$TASK_ROOT/resolve"
|
||||
META="$RUN_DIR/meta.json"
|
||||
|
||||
mkdir -p "$RUN_DIR/rounds" "$RUN_DIR/inbox-history"
|
||||
: > "$RUN_DIR/inbox.md"
|
||||
jq -n --arg pr "$PR" --arg repo "$REPO" '{
|
||||
pr_number:($pr|tonumber), repo:$repo,
|
||||
status:"active",
|
||||
round:0, max_rounds:15,
|
||||
last_processed_review_id:0,
|
||||
last_processed_review_comment_id:0,
|
||||
last_pushed_sha:null,
|
||||
consecutive_idle:0
|
||||
}' > "$META"
|
||||
|
||||
echo "preflight: state initialized for PR #$PR at $META" >&2
|
||||
echo "$META"
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
## Triage each unresolved thread and summary item
|
||||
|
||||
For each inline thread's root comment **and** each actionable item from review summaries, classify:
|
||||
|
||||
| Verdict | Criteria | Action |
|
||||
| ---------- | ----------------------------------------------------------------- | ------------------------------------- |
|
||||
| **Accept** | Feedback is correct and fix belongs in this PR's scope | Fix the code |
|
||||
| **Reject** | Feedback is incorrect, irrelevant, or based on a misunderstanding | Reply with reasoning |
|
||||
| **Defer** | Feedback is valid but fix would significantly expand PR scope | Acknowledge, explain why out of scope |
|
||||
|
||||
**Bias toward Accept.** Only defer when the fix would:
|
||||
|
||||
- Touch files/modules unrelated to the PR's stated purpose.
|
||||
- Require a design decision not yet made.
|
||||
- Be large enough to warrant its own review cycle.
|
||||
|
||||
Evaluate all feedback on merit, regardless of whether the reviewer is human or bot.
|
||||
|
||||
## Apply fixes
|
||||
|
||||
For each accepted comment, read the relevant file, understand the context, and make the **minimal** fix. Commit message: `[Chore][<scope>] address review feedback` where `<scope>` comes from the PR title's `[Type][Scope]` pattern (omit `[<scope>]` if the title has none).
|
||||
|
||||
## Reply and resolve threads; optional top-level note
|
||||
|
||||
### Inline threads (always)
|
||||
|
||||
For each thread, reply **inline** then resolve:
|
||||
|
||||
```bash
|
||||
# Reply
|
||||
gh api "repos/$REPO/pulls/$PR/comments/<root_comment_id>/replies" \
|
||||
-f body="<reply>"
|
||||
|
||||
# Resolve thread
|
||||
gh api graphql -f query='
|
||||
mutation($id:ID!) {
|
||||
resolveReviewThread(input:{threadId:$id}) {
|
||||
thread { isResolved }
|
||||
}
|
||||
}' -f id="<thread_node_id>"
|
||||
```
|
||||
|
||||
### Top-level PR comment (optional)
|
||||
|
||||
Skip if every item is covered by an inline reply. Only post when there is something inline can't carry: cross-cutting context, a defer rationale that spans the PR, or a one-line pointer to the fix commit.
|
||||
|
||||
```bash
|
||||
gh api "repos/$REPO/issues/$PR/comments" -f body="<top-level note>"
|
||||
```
|
||||
|
||||
**Deduplicate**: if multiple reviewers (or summary + inline) raise the same point, fix once, reference the same commit in each reply.
|
||||
|
||||
Resolve every thread that was replied to, regardless of verdict.
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# round-post.sh <PR_NUMBER>
|
||||
#
|
||||
# Post-round work for resolve-tileops: detect what changed during the
|
||||
# round (push, threads resolved), write the round summary, advance meta.
|
||||
# Reads baseline from $RUN_DIR/.round-pre.json (left by round-pre.sh).
|
||||
#
|
||||
# Stdout: single JSON line summary {round, pushed_sha, threads_resolved,
|
||||
# open_after}.
|
||||
# Stderr: human-readable status / errors.
|
||||
# Exit 0: round finalized. Non-zero: missing state.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PR="${1:?usage: round-post.sh <PR_NUMBER>}"
|
||||
[[ "$PR" =~ ^[0-9]+$ ]] || { echo "round-post: PR must be a positive integer" >&2; exit 1; }
|
||||
command -v gh >/dev/null 2>&1 || { echo "round-post: missing gh" >&2; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "round-post: missing jq" >&2; exit 1; }
|
||||
|
||||
REPO="tile-ai/TileOPs"
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Anchor state lookup to the main checkout (see preflight.sh).
|
||||
GIT_COMMON_DIR="$(git -C "$SKILL_DIR" rev-parse --git-common-dir 2>/dev/null)" \
|
||||
|| { echo "round-post: cannot resolve repo root from \$SKILL_DIR=$SKILL_DIR" >&2; exit 1; }
|
||||
[[ "$GIT_COMMON_DIR" != /* ]] && GIT_COMMON_DIR="$SKILL_DIR/$GIT_COMMON_DIR"
|
||||
REPO_PATH="$(cd "$GIT_COMMON_DIR/.." && pwd)"
|
||||
|
||||
META=""
|
||||
for m in "$REPO_PATH/.foundry/runs"/*/resolve/meta.json; do
|
||||
[[ -f "$m" ]] || continue
|
||||
if [[ "$(jq -r '.pr_number' "$m" 2>/dev/null)" = "$PR" ]]; then
|
||||
META="$m"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -n "$META" ]] || { echo "round-post: no state for PR #$PR" >&2; exit 1; }
|
||||
|
||||
RUN_DIR=$(dirname "$META")
|
||||
PRE_JSON="$RUN_DIR/.round-pre.json"
|
||||
[[ -f "$PRE_JSON" ]] \
|
||||
|| { echo "round-post: missing $PRE_JSON — was round-pre.sh run with action=continue?" >&2; exit 1; }
|
||||
|
||||
HEAD_SHA_BEFORE=$(jq -r '.head_sha' "$PRE_JSON")
|
||||
UNRESOLVED_BEFORE=$(jq -r '.unresolved_before' "$PRE_JSON")
|
||||
REVIEWER_STATE_BEFORE=$(jq -r '.reviewer_state_before' "$PRE_JSON")
|
||||
# Use the PRE-round watermark when advancing meta. If the reviewer added
|
||||
# new comments mid-round (after round-pre snapshotted but before
|
||||
# round-post), they'd be lost if we used the post-round max. Reading from
|
||||
# the baseline guarantees mid-round feedback is processed next round.
|
||||
PRE_LATEST_REVIEW_ID=$(jq -r '.latest_review_id' "$PRE_JSON")
|
||||
PRE_LATEST_REVIEW_COMMENT_ID=$(jq -r '.latest_review_comment_id' "$PRE_JSON")
|
||||
|
||||
NEW_HEAD_SHA=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq .headRefOid)
|
||||
PUSHED_SHA="none"
|
||||
[[ "$NEW_HEAD_SHA" != "$HEAD_SHA_BEFORE" ]] && PUSHED_SHA="$NEW_HEAD_SHA"
|
||||
|
||||
# Paginate reviewThreads — same rationale as round-pre.sh.
|
||||
NEW_UNRESOLVED=0
|
||||
cursor=''
|
||||
while :; do
|
||||
page=$(gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!,$after:String){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100, after:$after){
|
||||
nodes{ isResolved }
|
||||
pageInfo{ hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner=tile-ai -F repo=TileOPs -F pr="$PR" \
|
||||
${cursor:+-f after="$cursor"})
|
||||
page_unresolved=$(printf '%s' "$page" \
|
||||
| jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length')
|
||||
NEW_UNRESOLVED=$((NEW_UNRESOLVED + page_unresolved))
|
||||
has_next=$(printf '%s' "$page" \
|
||||
| jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
|
||||
[[ "$has_next" == "true" ]] || break
|
||||
cursor=$(printf '%s' "$page" \
|
||||
| jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
|
||||
done
|
||||
|
||||
# Clamp at 0 — new threads opened during the round can make the raw
|
||||
# delta negative, but reporting "-3 threads_resolved" is misleading.
|
||||
THREADS_RESOLVED_RAW=$(( UNRESOLVED_BEFORE - NEW_UNRESOLVED ))
|
||||
if (( THREADS_RESOLVED_RAW < 0 )); then
|
||||
THREADS_RESOLVED=0
|
||||
else
|
||||
THREADS_RESOLVED=$THREADS_RESOLVED_RAW
|
||||
fi
|
||||
|
||||
ROUND=$(jq -r '.round' "$META")
|
||||
NEXT_ROUND=$((ROUND + 1))
|
||||
N=$(printf '%02d' "$NEXT_ROUND")
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# Round summary file
|
||||
jq -n --argjson r "$NEXT_ROUND" --arg now "$NOW" \
|
||||
--arg sha_b "$HEAD_SHA_BEFORE" --arg sha_a "$NEW_HEAD_SHA" \
|
||||
--arg pushed "$PUSHED_SHA" \
|
||||
--argjson resolved "$THREADS_RESOLVED" \
|
||||
--argjson unresolved_after "$NEW_UNRESOLVED" \
|
||||
--arg reviewer_state "$REVIEWER_STATE_BEFORE" \
|
||||
'{round:$r, finished_at:$now, head_sha_before:$sha_b, head_sha_after:$sha_a,
|
||||
pushed_sha:$pushed, threads_resolved:$resolved,
|
||||
unresolved_after:$unresolved_after, reviewer_state_before:$reviewer_state}' \
|
||||
> "$RUN_DIR/rounds/round-$N.json"
|
||||
|
||||
# Advance meta. last_pushed_sha is sticky: only touched when a push
|
||||
# actually happened this round, preserving its prior value (null on a
|
||||
# fresh state, or the last real sha) otherwise. Watermarks come from the
|
||||
# PRE-round baseline so mid-round reviewer activity is picked up next
|
||||
# round.
|
||||
if [[ "$PUSHED_SHA" != "none" ]]; then
|
||||
jq --argjson r "$NEXT_ROUND" \
|
||||
--argjson rid "$PRE_LATEST_REVIEW_ID" \
|
||||
--argjson cid "$PRE_LATEST_REVIEW_COMMENT_ID" \
|
||||
--arg pushed "$PUSHED_SHA" \
|
||||
'.round=$r | .last_processed_review_id=$rid
|
||||
| .last_processed_review_comment_id=$cid
|
||||
| .last_pushed_sha=$pushed' \
|
||||
"$META" > "$META.tmp" && mv "$META.tmp" "$META"
|
||||
else
|
||||
jq --argjson r "$NEXT_ROUND" \
|
||||
--argjson rid "$PRE_LATEST_REVIEW_ID" \
|
||||
--argjson cid "$PRE_LATEST_REVIEW_COMMENT_ID" \
|
||||
'.round=$r | .last_processed_review_id=$rid
|
||||
| .last_processed_review_comment_id=$cid' \
|
||||
"$META" > "$META.tmp" && mv "$META.tmp" "$META"
|
||||
fi
|
||||
|
||||
rm -f "$PRE_JSON"
|
||||
|
||||
jq -n \
|
||||
--argjson r "$NEXT_ROUND" \
|
||||
--arg pushed "$PUSHED_SHA" \
|
||||
--argjson resolved "$THREADS_RESOLVED" \
|
||||
--argjson open_after "$NEW_UNRESOLVED" \
|
||||
'{round:$r, pushed_sha:$pushed, threads_resolved:$resolved, open_after:$open_after}'
|
||||
|
|
@ -1,360 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# round-pre.sh <PR_NUMBER>
|
||||
#
|
||||
# Pre-round work for resolve-tileops: locate state, snapshot the current
|
||||
# PR view, decide action (continue/idle/terminate). On 'continue', gather
|
||||
# this round's input snapshots and archive any inbox.
|
||||
#
|
||||
# Prerequisite: preflight.sh must have already initialized state for this
|
||||
# PR. round-pre.sh does NOT init; it errors if state is missing.
|
||||
#
|
||||
# Stdout: single JSON object describing the action and (on continue) the
|
||||
# snapshot prefix the skill body should read.
|
||||
# Stderr: human-readable status / errors.
|
||||
# Exit 0: action ready (skill body should branch on .action).
|
||||
# Exit non-zero: missing state or upstream failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PR="${1:?usage: round-pre.sh <PR_NUMBER>}"
|
||||
[[ "$PR" =~ ^[0-9]+$ ]] || { echo "round-pre: PR must be a positive integer" >&2; exit 1; }
|
||||
command -v gh >/dev/null 2>&1 || { echo "round-pre: missing gh" >&2; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "round-pre: missing jq" >&2; exit 1; }
|
||||
|
||||
REVIEWER_LOGIN="${RESOLVE_REVIEWER_LOGIN:-Ibuki-wind}"
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Anchor state lookup to the main checkout (see preflight.sh).
|
||||
GIT_COMMON_DIR="$(git -C "$SKILL_DIR" rev-parse --git-common-dir 2>/dev/null)" \
|
||||
|| { echo "round-pre: cannot resolve repo root from \$SKILL_DIR=$SKILL_DIR" >&2; exit 1; }
|
||||
[[ "$GIT_COMMON_DIR" != /* ]] && GIT_COMMON_DIR="$SKILL_DIR/$GIT_COMMON_DIR"
|
||||
REPO_PATH="$(cd "$GIT_COMMON_DIR/.." && pwd)"
|
||||
|
||||
# Locate state. preflight.sh must have created it.
|
||||
META=""
|
||||
for m in "$REPO_PATH/.foundry/runs"/*/resolve/meta.json; do
|
||||
[[ -f "$m" ]] || continue
|
||||
if [[ "$(jq -r '.pr_number' "$m" 2>/dev/null)" = "$PR" ]]; then
|
||||
META="$m"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -n "$META" ]] \
|
||||
|| { echo "round-pre: no state for PR #$PR — run preflight.sh first" >&2; exit 1; }
|
||||
|
||||
RUN_DIR=$(dirname "$META")
|
||||
|
||||
ROUND=$(jq -r '.round' "$META")
|
||||
MAX_ROUNDS=$(jq -r '.max_rounds' "$META")
|
||||
LAST_REVIEW_ID_PREV=$(jq -r '.last_processed_review_id' "$META")
|
||||
LAST_REVIEW_COMMENT_ID_PREV=$(jq -r '.last_processed_review_comment_id' "$META")
|
||||
# Stall safety net. Increment on idle (no progress this round), reset on
|
||||
# continue. Hitting MAX_IDLE terminates the loop so a dead counterpart
|
||||
# (e.g. review-loop crashed) doesn't leave us polling forever. Hardcoded
|
||||
# rather than read from meta.json so a state file from an older skill
|
||||
# version with a stricter threshold doesn't silently override the
|
||||
# current floor.
|
||||
CONSECUTIVE_IDLE=$(jq -r '.consecutive_idle // 0' "$META")
|
||||
MAX_IDLE=20
|
||||
|
||||
# Pin `gh pr view` to the repo recorded in meta.json. Preflight already
|
||||
# stamped this to the canonical base repo; without `--repo`, gh defaults
|
||||
# to the worktree's origin remote, which in a fork checkout points at
|
||||
# the contributor's fork and either fails or fetches the wrong PR.
|
||||
META_REPO=$(jq -r '.repo // empty' "$META")
|
||||
[[ -n "$META_REPO" ]] \
|
||||
|| { echo "round-pre: meta.json missing .repo — re-run preflight.sh" >&2; exit 1; }
|
||||
[[ "$META_REPO" =~ ^[^/]+/[^/]+$ ]] \
|
||||
|| { echo "round-pre: meta.json .repo='$META_REPO' must be exactly 'owner/name' (single slash, both halves non-empty) — re-run preflight.sh" >&2; exit 1; }
|
||||
PR_JSON=$(gh pr view "$PR" --repo "$META_REPO" --json state,headRefOid,isDraft 2>/dev/null) \
|
||||
|| { echo "round-pre: gh pr view failed" >&2; exit 1; }
|
||||
PR_STATE=$(echo "$PR_JSON" | jq -r .state)
|
||||
HEAD_SHA=$(echo "$PR_JSON" | jq -r .headRefOid)
|
||||
# Split META_REPO into owner/name for the downstream
|
||||
# `gh api repos/<OWNER>/<NAME>/...` calls. Older versions of this
|
||||
# script derived these from a `baseRepository` json field, which gh
|
||||
# CLI 2.88.1 dropped.
|
||||
REPO_OWNER="${META_REPO%%/*}"
|
||||
REPO_NAME="${META_REPO##*/}"
|
||||
REPO="$REPO_OWNER/$REPO_NAME"
|
||||
|
||||
# Reviews + inline comments — paginate so PRs with >1 page don't
|
||||
# silently lose the latest IDs / state.
|
||||
# --slurp + --jq are mutually exclusive in `gh api`; pipe through
|
||||
# external jq instead. --paginate --slurp produces an array of pages
|
||||
# (each page is itself an array of items), so jq flattens with [.[][]].
|
||||
ALL_REVIEWS=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/reviews")
|
||||
ALL_COMMENTS=$(gh api --paginate --slurp "repos/$REPO/pulls/$PR/comments")
|
||||
LATEST_REVIEWER_STATE=$(printf '%s' "$ALL_REVIEWS" \
|
||||
| jq -r "[.[][]|select(.user.login==\"$REVIEWER_LOGIN\")] | sort_by(.submitted_at) | last | .state // \"NONE\"")
|
||||
LATEST_REVIEW_ID=$(printf '%s' "$ALL_REVIEWS" \
|
||||
| jq -r "[.[][]|select(.user.login==\"$REVIEWER_LOGIN\")|.id]|max // 0")
|
||||
LATEST_REVIEW_COMMENT_ID=$(printf '%s' "$ALL_COMMENTS" \
|
||||
| jq -r "[.[][]|select(.user.login==\"$REVIEWER_LOGIN\")|.id]|max // 0")
|
||||
|
||||
# Unresolved review thread count — paginate via cursor so PRs with
|
||||
# >100 threads don't undercount.
|
||||
count_unresolved() {
|
||||
local cursor='' total=0 page page_unresolved has_next
|
||||
while :; do
|
||||
page=$(gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!,$after:String){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100, after:$after){
|
||||
nodes{ isResolved }
|
||||
pageInfo{ hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner="$REPO_OWNER" -F repo="$REPO_NAME" -F pr="$PR" \
|
||||
${cursor:+-f after="$cursor"})
|
||||
page_unresolved=$(printf '%s' "$page" \
|
||||
| jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)]|length')
|
||||
total=$((total + page_unresolved))
|
||||
has_next=$(printf '%s' "$page" \
|
||||
| jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
|
||||
[[ "$has_next" == "true" ]] || break
|
||||
cursor=$(printf '%s' "$page" \
|
||||
| jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
|
||||
done
|
||||
echo "$total"
|
||||
}
|
||||
UNRESOLVED=$(count_unresolved)
|
||||
|
||||
# Decide action — first match wins. PR_STATE==DRAFT does not stop.
|
||||
ACTION=""; MESSAGE=""
|
||||
case "$PR_STATE" in
|
||||
MERGED|CLOSED) ACTION="terminate-external"; MESSAGE="PR #$PR is $PR_STATE — stopping." ;;
|
||||
esac
|
||||
if [[ -z "$ACTION" && "$ROUND" -ge "$MAX_ROUNDS" ]]; then
|
||||
ACTION="terminate-diverged"
|
||||
MESSAGE="Reached max rounds ($MAX_ROUNDS) — human attention needed."
|
||||
fi
|
||||
if [[ -z "$ACTION" \
|
||||
&& "$UNRESOLVED" -eq 0 \
|
||||
&& "$LATEST_REVIEWER_STATE" == "APPROVED" \
|
||||
&& "$LATEST_REVIEW_ID" == "$LAST_REVIEW_ID_PREV" \
|
||||
&& "$LATEST_REVIEW_COMMENT_ID" == "$LAST_REVIEW_COMMENT_ID_PREV" ]]; then
|
||||
# Approve + everything processed → exit. Watermark equality on both
|
||||
# review-id and comment-id ensures we don't skip a not-yet-processed
|
||||
# APPROVE review body that has no inline comments.
|
||||
ACTION="terminate-success"
|
||||
MESSAGE="PR #$PR converged — all threads resolved, reviewer approved."
|
||||
fi
|
||||
|
||||
# Idle gate: only sleep when there's nothing to do. Unresolved threads
|
||||
# (from any source) override idle — the dev should still process them
|
||||
# even if the canonical reviewer hasn't posted new activity.
|
||||
if [[ -z "$ACTION" \
|
||||
&& "$UNRESOLVED" -eq 0 \
|
||||
&& "$LATEST_REVIEW_ID" == "$LAST_REVIEW_ID_PREV" \
|
||||
&& "$LATEST_REVIEW_COMMENT_ID" == "$LAST_REVIEW_COMMENT_ID_PREV" ]]; then
|
||||
ACTION="idle"
|
||||
MESSAGE="No new review feedback for PR #$PR — sleeping."
|
||||
fi
|
||||
|
||||
[[ -z "$ACTION" ]] && ACTION="continue"
|
||||
|
||||
# Stall counter: increment on idle, reset on continue. If idle persists
|
||||
# beyond max_idle, escalate to terminate-stalled — protects against the
|
||||
# review-loop dying silently while we poll forever.
|
||||
if [[ "$ACTION" == "idle" ]]; then
|
||||
NEW_IDLE=$((CONSECUTIVE_IDLE + 1))
|
||||
if (( NEW_IDLE >= MAX_IDLE )); then
|
||||
ACTION="terminate-stalled"
|
||||
MESSAGE="No reviewer activity for $NEW_IDLE consecutive rounds (max_idle=$MAX_IDLE) — terminating."
|
||||
fi
|
||||
jq --argjson n "$NEW_IDLE" '.consecutive_idle=$n' "$META" \
|
||||
> "$META.tmp" && mv "$META.tmp" "$META"
|
||||
elif [[ "$ACTION" == "continue" ]]; then
|
||||
if (( CONSECUTIVE_IDLE != 0 )); then
|
||||
jq '.consecutive_idle=0' "$META" \
|
||||
> "$META.tmp" && mv "$META.tmp" "$META"
|
||||
fi
|
||||
fi
|
||||
|
||||
NEXT_ROUND=$((ROUND + 1))
|
||||
SNAP_PREFIX=""
|
||||
|
||||
if [[ "$ACTION" == "continue" ]]; then
|
||||
N=$(printf '%02d' "$NEXT_ROUND")
|
||||
SNAP_PREFIX="$RUN_DIR/rounds/round-$N"
|
||||
mkdir -p "$RUN_DIR/rounds"
|
||||
|
||||
printf '%s' "$ALL_REVIEWS" \
|
||||
| jq "[.[][]|select(.user.login==\"$REVIEWER_LOGIN\" and .id>$LAST_REVIEW_ID_PREV)|{id,state,body,submitted_at}]" \
|
||||
> "$SNAP_PREFIX.new-reviews.json"
|
||||
|
||||
printf '%s' "$ALL_COMMENTS" \
|
||||
| jq "[.[][]|select(.user.login==\"$REVIEWER_LOGIN\" and .id>$LAST_REVIEW_COMMENT_ID_PREV)|{id,path,line,body,in_reply_to_id,created_at}]" \
|
||||
> "$SNAP_PREFIX.new-inline-comments.json"
|
||||
|
||||
# Snapshot ALL unresolved threads (paginated). Inner comments() is
|
||||
# also paginated below: the auto-resolver's whole-thread author check
|
||||
# depends on seeing every comment, so a thread that overflows the
|
||||
# first 100 comments would otherwise look bot-only and get
|
||||
# mis-resolved.
|
||||
: > "$SNAP_PREFIX.unresolved-threads.json"
|
||||
collected=()
|
||||
cursor=''
|
||||
while :; do
|
||||
page=$(gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!,$after:String){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100, after:$after){
|
||||
nodes{
|
||||
id isResolved
|
||||
comments(first:100){
|
||||
pageInfo{ hasNextPage endCursor }
|
||||
nodes{
|
||||
id databaseId author{login} body path line
|
||||
commit{ oid }
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo{ hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner="$REPO_OWNER" -F repo="$REPO_NAME" -F pr="$PR" \
|
||||
${cursor:+-f after="$cursor"})
|
||||
items=$(printf '%s' "$page" \
|
||||
| jq -c '.data.repository.pullRequest.reviewThreads.nodes|map(select(.isResolved==false))[]')
|
||||
if [[ -n "$items" ]]; then
|
||||
while IFS= read -r line; do
|
||||
collected+=("$line")
|
||||
done <<< "$items"
|
||||
fi
|
||||
has_next=$(printf '%s' "$page" \
|
||||
| jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
|
||||
[[ "$has_next" == "true" ]] || break
|
||||
cursor=$(printf '%s' "$page" \
|
||||
| jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
|
||||
done
|
||||
|
||||
# Per-thread comments completion: any thread whose first comments page
|
||||
# was truncated gets follow-up node(id) queries until exhausted, then
|
||||
# the new nodes are merged into the thread's comments.nodes array.
|
||||
# Without this, the whole-thread author check in auto-resolve-stale.sh
|
||||
# could miss a human reply that landed past comment #100.
|
||||
for i in "${!collected[@]}"; do
|
||||
thread="${collected[$i]}"
|
||||
has_more=$(printf '%s' "$thread" | jq -r '.comments.pageInfo.hasNextPage // false')
|
||||
[[ "$has_more" != "true" ]] && continue
|
||||
cursor=$(printf '%s' "$thread" | jq -r '.comments.pageInfo.endCursor')
|
||||
thread_id=$(printf '%s' "$thread" | jq -r '.id')
|
||||
extra_nodes='[]'
|
||||
while :; do
|
||||
page=$(gh api graphql -f query='
|
||||
query($id:ID!,$after:String){
|
||||
node(id:$id){
|
||||
... on PullRequestReviewThread{
|
||||
comments(first:100, after:$after){
|
||||
pageInfo{ hasNextPage endCursor }
|
||||
nodes{
|
||||
id databaseId author{login} body path line
|
||||
commit{ oid }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F id="$thread_id" -f after="$cursor")
|
||||
page_nodes=$(printf '%s' "$page" | jq -c '.data.node.comments.nodes')
|
||||
extra_nodes=$(jq -nc --argjson a "$extra_nodes" --argjson b "$page_nodes" '$a + $b')
|
||||
has_next=$(printf '%s' "$page" | jq -r '.data.node.comments.pageInfo.hasNextPage')
|
||||
[[ "$has_next" == "true" ]] || break
|
||||
cursor=$(printf '%s' "$page" | jq -r '.data.node.comments.pageInfo.endCursor')
|
||||
done
|
||||
collected[$i]=$(printf '%s' "$thread" \
|
||||
| jq -c --argjson extra "$extra_nodes" \
|
||||
'.comments.nodes = (.comments.nodes + $extra) | .comments.pageInfo.hasNextPage = false')
|
||||
done
|
||||
|
||||
# Emit the final array.
|
||||
if (( ${#collected[@]} == 0 )); then
|
||||
echo '[]' > "$SNAP_PREFIX.unresolved-threads.json"
|
||||
else
|
||||
{
|
||||
echo '['
|
||||
first_page=1
|
||||
for thread in "${collected[@]}"; do
|
||||
[[ "$first_page" -eq 1 ]] && first_page=0 || echo ','
|
||||
printf '%s' "$thread"
|
||||
done
|
||||
echo ']'
|
||||
} > "$SNAP_PREFIX.unresolved-threads.json"
|
||||
fi
|
||||
|
||||
# Stale-bot auto-resolve: scoped to known bot identities anchored to a
|
||||
# commit older than current HEAD. Humans and bots-at-HEAD are skipped;
|
||||
# unknown bot-like logins are recorded for human triage. The classifier
|
||||
# consumes the unresolved-threads snapshot directly so the pagination
|
||||
# contract above is the single source of truth.
|
||||
# Contract: $SNAP_PREFIX.auto-resolve.json is ALWAYS present after
|
||||
# round-pre completes. Downstream consumers may rely on the file
|
||||
# existing with the standard shape `{resolve, unknown_bot_like, skip}`.
|
||||
# On classifier-missing or classifier-crash we still emit a valid empty
|
||||
# plan plus an `error` field so the failure is visible without breaking
|
||||
# the consumer's jq pipeline.
|
||||
AR_OUT="$SNAP_PREFIX.auto-resolve.json"
|
||||
if [[ -x "$SKILL_DIR/auto-resolve-stale.sh" && -f "$SKILL_DIR/known-bots.json" ]]; then
|
||||
AR_INPUT="$SNAP_PREFIX.auto-resolve-input.json"
|
||||
jq -n --arg sha "$HEAD_SHA" \
|
||||
--slurpfile threads "$SNAP_PREFIX.unresolved-threads.json" \
|
||||
'{head_sha:$sha, threads:$threads[0]}' > "$AR_INPUT"
|
||||
# Write to a tmpfile and only mv into place on success — guarantees
|
||||
# downstream readers never consume a half-written / empty file when
|
||||
# the classifier crashes mid-emit.
|
||||
AR_TMP="$AR_OUT.tmp"
|
||||
if "$SKILL_DIR/auto-resolve-stale.sh" \
|
||||
--threads "$AR_INPUT" \
|
||||
--bots "$SKILL_DIR/known-bots.json" \
|
||||
--run-dir "$RUN_DIR" \
|
||||
--round "$N" \
|
||||
> "$AR_TMP"; then
|
||||
mv "$AR_TMP" "$AR_OUT"
|
||||
else
|
||||
echo "round-pre: auto-resolve-stale exited non-zero" >&2
|
||||
rm -f "$AR_TMP"
|
||||
jq -n --arg err "auto-resolve-stale exited non-zero" \
|
||||
'{resolve:[], unknown_bot_like:[], skip:[], error:$err}' > "$AR_OUT"
|
||||
fi
|
||||
else
|
||||
jq -n --arg err "auto-resolve-stale.sh or known-bots.json missing" \
|
||||
'{resolve:[], unknown_bot_like:[], skip:[], error:$err}' > "$AR_OUT"
|
||||
fi
|
||||
|
||||
gh pr checks "$PR" --repo "$REPO" --json name,state,conclusion \
|
||||
> "$SNAP_PREFIX.ci.json" 2>/dev/null || echo '[]' > "$SNAP_PREFIX.ci.json"
|
||||
|
||||
# Archive inbox for this round, then clear it. Skill body reads the
|
||||
# archived copy if it wants this round's guidance. Ensure inbox-history
|
||||
# exists in case the state dir was partially deleted/corrupted.
|
||||
if [[ -s "$RUN_DIR/inbox.md" ]]; then
|
||||
mkdir -p "$RUN_DIR/inbox-history"
|
||||
mv "$RUN_DIR/inbox.md" "$RUN_DIR/inbox-history/round-$N.md"
|
||||
: > "$RUN_DIR/inbox.md"
|
||||
fi
|
||||
|
||||
# Persist baseline so round-post.sh can compute deltas without
|
||||
# re-querying. Critically, persist LATEST_REVIEW_ID and
|
||||
# LATEST_REVIEW_COMMENT_ID so round-post advances the watermark to the
|
||||
# PRE-round max — items that arrive mid-round get picked up next round.
|
||||
jq -n --arg sha "$HEAD_SHA" \
|
||||
--argjson unresolved "$UNRESOLVED" \
|
||||
--arg state "$LATEST_REVIEWER_STATE" \
|
||||
--argjson rid "$LATEST_REVIEW_ID" \
|
||||
--argjson cid "$LATEST_REVIEW_COMMENT_ID" \
|
||||
'{head_sha:$sha, unresolved_before:$unresolved, reviewer_state_before:$state,
|
||||
latest_review_id:$rid, latest_review_comment_id:$cid}' \
|
||||
> "$RUN_DIR/.round-pre.json"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg action "$ACTION" \
|
||||
--arg run_dir "$RUN_DIR" \
|
||||
--arg snap_prefix "$SNAP_PREFIX" \
|
||||
--argjson round "$NEXT_ROUND" \
|
||||
--arg message "$MESSAGE" \
|
||||
'{action:$action, round:$round, run_dir:$run_dir, snap_prefix:$snap_prefix, message:$message}'
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
# review-tileops setup
|
||||
|
||||
This skill reviews PRs as a GitHub identity distinct from the PR author. The identity is per-developer machine config — it is not in the repo.
|
||||
|
||||
## One-time setup
|
||||
|
||||
1. Have a second GitHub account (separate from the one you author PRs with).
|
||||
|
||||
1. Pick any directory path you like, then authenticate `gh` against it as that account:
|
||||
|
||||
```bash
|
||||
GH_CONFIG_DIR=/path/to/your/reviewer-config gh auth login --hostname github.com
|
||||
```
|
||||
|
||||
1. Export the same path in your shell rc:
|
||||
|
||||
```bash
|
||||
export TILEOPS_REVIEW_GH_CONFIG_DIR=/path/to/your/reviewer-config
|
||||
```
|
||||
|
||||
## Before reviewing each PR
|
||||
|
||||
Run once per PR (the loop driver does this automatically; for a manual single-shot review, run it yourself):
|
||||
|
||||
```bash
|
||||
bash .claude/skills/review-tileops/preflight.sh <PR_NUMBER>
|
||||
```
|
||||
|
||||
It verifies the env var, the `hosts.yml`, and that the reviewer login is distinct from the PR author. The skill itself does no preflight — it assumes this has passed.
|
||||
|
||||
## If preflight errors
|
||||
|
||||
Each error message names the next command to run. Follow it.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
name: review-tileops
|
||||
description: Single-shot review of a tile-ai/TileOPs PR as a separate GitHub identity from the PR author. Manual / interactive — for autonomous multi-round review until APPROVE, run `bash .claude/skills/review-tileops/loop.sh <PR>` instead.
|
||||
---
|
||||
|
||||
## Input
|
||||
|
||||
`$ARGUMENTS`: integer PR number in `tile-ai/TileOPs`. E.g. `1122`.
|
||||
|
||||
## Step 0: Preflight
|
||||
|
||||
```bash
|
||||
bash .claude/skills/review-tileops/preflight.sh <PR> || exit 1
|
||||
export GH_CONFIG_DIR="$TILEOPS_REVIEW_GH_CONFIG_DIR"
|
||||
```
|
||||
|
||||
## Step 1: Gather inputs
|
||||
|
||||
```bash
|
||||
gh pr view <PR> --repo tile-ai/TileOPs \
|
||||
--json number,title,body,files,headRefOid,state
|
||||
gh pr diff <PR> --repo tile-ai/TileOPs
|
||||
```
|
||||
|
||||
Parse the title's first two bracket tokens — `[type][scope] description`. Look both up in `loading.yaml`:
|
||||
|
||||
- Always load entries under `always:`.
|
||||
- For each token, if it appears as a key under `match:`, load the listed checklist files.
|
||||
- Multi-match across the two tokens → union. Both unmatched → no domain checklist; rely on general judgment.
|
||||
|
||||
Read each loaded checklist file under `.claude/review-checklists/`, plus `criteria.md`. Skim unresolved review threads (`gh api graphql … reviewThreads`), recent non-reviewer comments (`gh api repos/.../issues/<N>/comments` and `…/pulls/<N>/comments`), and CI status (`gh pr checks <N>`) if relevant to the diff.
|
||||
|
||||
## Step 2: Execute the review
|
||||
|
||||
Follow `procedure.md` end to end. Submit one atomic review at the end.
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
### 1. Submit
|
||||
|
||||
```bash
|
||||
gh api repos/tile-ai/TileOPs/pulls/<N>/reviews \
|
||||
-f event="<EVENT>" \
|
||||
-f body="<SUMMARY>" \
|
||||
-f 'comments=[{"path":"<file>","line":<line>,"body":"<comment>"}, ...]'
|
||||
```
|
||||
|
||||
`<EVENT>`: `REQUEST_CHANGES` if any blocking issue, `APPROVE` if clean, `COMMENT` for non-blocking questions only.
|
||||
|
||||
### 2. Inline format
|
||||
|
||||
```
|
||||
<what is wrong and why> → <what to change>
|
||||
```
|
||||
|
||||
One comment per issue. Name the function, variable, or pattern. The reader is an agent that executes fixes literally.
|
||||
|
||||
### 3. Summary format (markdown)
|
||||
|
||||
The summary is for what does **not** fit in an inline comment. Per-file issues belong inline (§2), not in a summary list. Omit empty sections.
|
||||
|
||||
```
|
||||
### Overall
|
||||
<one line: top risk + next step. No event echo, no item list.>
|
||||
|
||||
### Cross-cutting concerns
|
||||
- <pattern spanning multiple files — only what an inline comment can't carry>
|
||||
```
|
||||
|
||||
Hard rules:
|
||||
|
||||
- Do NOT restate inline comments. If a finding is already inline, it must not appear in the summary.
|
||||
- Per-file/per-line items go in the `comments=[...]` array of §1, never in the summary body.
|
||||
- Clean PR: one line, `Clean — no issues.`
|
||||
|
||||
### 4. Hard rules
|
||||
|
||||
- Do not comment outside the PR diff.
|
||||
- Do not invent issues on a clean PR.
|
||||
- No eager design-doc reads — only when a guard names a doc AND the diff makes that item ambiguous, or the divergence trigger fires.
|
||||
- All review text in English.
|
||||
- All `gh` calls run with `GH_CONFIG_DIR` already exported by the caller. Repo is fixed at `tile-ai/TileOPs`.
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# PR title → review-checklists mapping.
|
||||
#
|
||||
# A TileOPs PR title always has the form `[type][scope] description` (the
|
||||
# scope bracket is optional but conventional). The loop / single-shot
|
||||
# review checks BOTH bracket tokens against `match` below; any token that
|
||||
# matches contributes its checklists. Multi-match across the two tokens
|
||||
# unions; no-match in either contributes nothing. If both miss, the
|
||||
# review runs without any domain-specific checklist (the reviewer
|
||||
# applies general judgment).
|
||||
#
|
||||
# Add a new entry here when a new type or scope earns a dedicated
|
||||
# checklist; do NOT add path-based triggers — keep title-driven matching
|
||||
# as the single rule. If a PR's title misclassifies its content,
|
||||
# iterate the title.
|
||||
|
||||
# Always loaded, regardless of title match.
|
||||
always:
|
||||
- pre-review.md
|
||||
|
||||
# Token → list of checklist files under .claude/review-checklists/.
|
||||
match:
|
||||
Feat: [feature.md]
|
||||
Enhancement: [feature.md]
|
||||
Refactor: [refactor.md]
|
||||
Maintain: [manifest.md]
|
||||
Manifest: [manifest.md]
|
||||
Doc: [doc.md]
|
||||
Design: [doc.md]
|
||||
Test: [testing.md]
|
||||
Bench: [benchmark.md]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,37 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run once per PR, before the first review round, to verify reviewer-identity
|
||||
# wiring. Subsequent rounds (whether single-shot or loop-driven) skip these
|
||||
# checks — the skill itself assumes preflight has passed.
|
||||
set -euo pipefail
|
||||
|
||||
PR="${1:?usage: preflight.sh <PR_NUMBER>}"
|
||||
REPO="tile-ai/TileOPs"
|
||||
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
echo "error: codex CLI not found in PATH." >&2
|
||||
echo " the review loop runs each round under Codex; install it from https://github.com/openai/codex" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${TILEOPS_REVIEW_GH_CONFIG_DIR:-}" ]; then
|
||||
echo "error: TILEOPS_REVIEW_GH_CONFIG_DIR is not set." >&2
|
||||
echo " export it in your shell rc; see .claude/skills/review-tileops/README.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$TILEOPS_REVIEW_GH_CONFIG_DIR/hosts.yml" ]; then
|
||||
echo "error: $TILEOPS_REVIEW_GH_CONFIG_DIR/hosts.yml not found." >&2
|
||||
echo " run: GH_CONFIG_DIR=$TILEOPS_REVIEW_GH_CONFIG_DIR gh auth login --hostname github.com" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REVIEWER=$(GH_CONFIG_DIR="$TILEOPS_REVIEW_GH_CONFIG_DIR" gh api user --jq .login)
|
||||
AUTHOR=$(GH_CONFIG_DIR="$TILEOPS_REVIEW_GH_CONFIG_DIR" gh pr view "$PR" --repo "$REPO" --json author --jq .author.login)
|
||||
|
||||
if [ "$REVIEWER" = "$AUTHOR" ]; then
|
||||
echo "error: reviewer ($REVIEWER) equals PR #$PR author." >&2
|
||||
echo " TILEOPS_REVIEW_GH_CONFIG_DIR points at the author's gh config; reviewer must be distinct." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: reviewer=$REVIEWER PR #$PR author=$AUTHOR (distinct)"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
**Free-form review (step 2) is the primary review step.** Project-specific guards (step 4) are a regression net applied last — they exist because the free-form pass is fallible at known classes of issue, NOT because they replace it. Skipping or shortening step 2 to lean on guards is a failure mode.
|
||||
|
||||
1. **Read every changed source file in full.** The diff alone lacks surrounding context.
|
||||
1. **Free-form review (primary).** Flag: logic errors, edge cases, API misuse, races, resource leaks, broken invariants, error-handling gaps, perf regressions, dead code, unclear names, missing tests. This is where the bulk of your reasoning belongs.
|
||||
1. **Triage unresolved review threads.** For each thread, judge whether the developer's latest change resolves the concern. If unresolved, surface as an inline comment.
|
||||
1. **Apply each loaded project-specific guard (last, supplement).** Walk its bullets against the diff. Add anything new to the step-2 findings.
|
||||
1. **Approval-gate decision.** If your draft event is `APPROVE`: read `.claude/review-checklists/approval-gate.md` and run every applicable check (each item self-scopes to the diff per the file's preamble). Downgrade to `REQUEST_CHANGES` if any fail.
|
||||
1. **Compose inline comments** per `criteria.md` §2 — one per issue, format `<what is wrong and why> → <what to change>`. Name the function, variable, or pattern.
|
||||
1. **Compose the summary** per `criteria.md` §3. Omit empty sections. A clean PR gets a single line: `Clean — no issues.`
|
||||
1. **Submit one atomic review** per `criteria.md` §1, with the inline comments in the `comments=[…]` array. Follow `criteria.md` §4 hard rules.
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# round-post.sh — post-codex hook for the review loop.
|
||||
#
|
||||
# Implements Rule 2 (same-path 5-strike monitor): track per-path
|
||||
# consecutive-blocker streaks across rounds. When any path's streak
|
||||
# reaches >= 5 consecutive rounds with at least one blocker on it,
|
||||
# ensure the PR carries the GitHub label "agent-stuck" so a human
|
||||
# can take a look.
|
||||
#
|
||||
# Monitoring-only: this hook MUST NOT modify the round's blockers,
|
||||
# the unresolved-threads file, the new-review-comments.json file,
|
||||
# the PR title, or post any comment. Its only side-effects on the
|
||||
# PR are GitHub label operations (idempotent).
|
||||
#
|
||||
# Inputs (env or positional, env wins):
|
||||
# RUN_DIR — review/ root for this loop run.
|
||||
# ROUND — integer round number that just finished.
|
||||
# COMMENTS_JSON — path to round-NN.codex-blockers.json (the post-
|
||||
# codex snapshot of review comments authored by
|
||||
# REVIEWER_LOGIN this round; used as the source of
|
||||
# blocker paths). The pre-codex
|
||||
# round-NN.new-review-comments.json is the WRONG
|
||||
# artifact — it explicitly filters out the reviewer
|
||||
# and contains human comments instead.
|
||||
# REPO — "owner/repo" string for `gh` operations.
|
||||
# Default: tile-ai/TileOPs.
|
||||
# PR — PR number for `gh pr edit`.
|
||||
#
|
||||
# Positional fallback: round-post.sh <RUN_DIR> <ROUND> <COMMENTS_JSON> <REPO> <PR>
|
||||
#
|
||||
# Optional env (for testing):
|
||||
# GH_BIN — gh executable to use (default: "gh"). Tests can
|
||||
# point this at a stub. If empty or "none", label
|
||||
# operations are skipped entirely (monitor still
|
||||
# updates region-history.json).
|
||||
#
|
||||
# Output: exit 0 on success. Logs to stderr; nothing on stdout.
|
||||
set -euo pipefail
|
||||
|
||||
RUN_DIR="${RUN_DIR:-${1:-}}"
|
||||
ROUND="${ROUND:-${2:-}}"
|
||||
COMMENTS_JSON="${COMMENTS_JSON:-${3:-}}"
|
||||
REPO="${REPO:-${4:-tile-ai/TileOPs}}"
|
||||
PR="${PR:-${5:-}}"
|
||||
GH_BIN="${GH_BIN:-gh}"
|
||||
|
||||
if [[ -z "$RUN_DIR" || -z "$ROUND" || -z "$COMMENTS_JSON" ]]; then
|
||||
echo "round-post.sh: missing args; usage: RUN_DIR=... ROUND=... COMMENTS_JSON=... [REPO=... PR=...] round-post.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HISTORY="$RUN_DIR/region-history.json"
|
||||
THRESHOLD=5
|
||||
LABEL="agent-stuck"
|
||||
LABEL_DESC="Autonomous loop appears stuck and needs human attention"
|
||||
LABEL_COLOR="FBCA04"
|
||||
|
||||
# --- 1. Extract blocker paths from this round's review comments ---
|
||||
#
|
||||
# Source-of-truth contract: this round's codex-blockers.json is the
|
||||
# post-codex snapshot of review comments authored by the reviewer login
|
||||
# (i.e. comments codex itself just posted as part of this review).
|
||||
# Entries with severity=blocker (or category=blocker) carry the .path
|
||||
# the reviewer flagged. We treat any entry whose .severity or .category
|
||||
# is "blocker" as a blocker. If neither field exists (older format or
|
||||
# free-form review), every entry counts. Round-post is monitoring-only:
|
||||
# the only side-effect is the `agent-stuck` label, which is itself
|
||||
# idempotent and safe to apply early. Over-counting accelerates the
|
||||
# threshold (i.e. the label may land sooner than a strict-blocker count
|
||||
# would warrant); it never produces an unsafe action.
|
||||
PATHS_THIS_ROUND=()
|
||||
if [[ -f "$COMMENTS_JSON" ]]; then
|
||||
# tolerate empty / malformed file by falling back to []
|
||||
if ! jq empty "$COMMENTS_JSON" >/dev/null 2>&1; then
|
||||
echo "round-post.sh: $COMMENTS_JSON is not valid JSON; treating as empty" >&2
|
||||
elif ! jq -e 'type == "array"' "$COMMENTS_JSON" >/dev/null 2>&1; then
|
||||
# `jq empty` accepts ANY valid JSON, including objects. If the
|
||||
# artifact is e.g. `{path:"x"}` (a single comment object, or an
|
||||
# error-wrapper produced by a failed gh call), `.[]` would extract
|
||||
# its values and miscount them as blocker paths. Require an array
|
||||
# before iterating; non-array input is treated as no blockers this
|
||||
# round (counters will reset, label stays unchanged).
|
||||
echo "round-post.sh: $COMMENTS_JSON is not a JSON array; treating as empty" >&2
|
||||
else
|
||||
while IFS= read -r p; do
|
||||
[[ -n "$p" && "$p" != "null" ]] && PATHS_THIS_ROUND+=("$p")
|
||||
done < <(jq -r '
|
||||
[
|
||||
.[]
|
||||
| select(
|
||||
(.severity // "" | ascii_downcase) == "blocker"
|
||||
or (.category // "" | ascii_downcase) == "blocker"
|
||||
or ((.severity // "") == "" and (.category // "") == "")
|
||||
)
|
||||
| .path // empty
|
||||
]
|
||||
| unique
|
||||
| .[]' "$COMMENTS_JSON")
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. Update region-history.json: increment present, reset absent ---
|
||||
#
|
||||
# Schema (stable across rounds; loaded by future tools):
|
||||
# {
|
||||
# "counters": { "<path>": <int>, ... },
|
||||
# "events": [ {"round": int, "path": str, "comment_ids": [int]}, ... ]
|
||||
# }
|
||||
#
|
||||
# Backward compat: missing file → start from empty state.
|
||||
# Malformed file → also reset to empty state (don't let bad JSON kill
|
||||
# the monitor; counters are best-effort).
|
||||
if [[ ! -f "$HISTORY" ]] || ! jq empty "$HISTORY" 2>/dev/null; then
|
||||
echo '{"counters":{},"events":[]}' > "$HISTORY"
|
||||
fi
|
||||
|
||||
# Build a JSON array of paths-this-round for jq.
|
||||
PATHS_JSON=$(printf '%s\n' "${PATHS_THIS_ROUND[@]:-}" \
|
||||
| jq -R . \
|
||||
| jq -s 'map(select(. != ""))')
|
||||
|
||||
NEW_HISTORY=$(jq \
|
||||
--argjson paths "$PATHS_JSON" \
|
||||
'
|
||||
. as $h
|
||||
| (.counters // {}) as $cur
|
||||
# increment counters for paths present this round; reset absent.
|
||||
| (
|
||||
($paths | map({key:., value: (($cur[.] // 0) + 1)}) | from_entries)
|
||||
) as $next
|
||||
| .counters = $next
|
||||
| .events = (.events // [])
|
||||
' "$HISTORY")
|
||||
|
||||
printf '%s\n' "$NEW_HISTORY" > "$HISTORY.tmp" && mv "$HISTORY.tmp" "$HISTORY"
|
||||
|
||||
# --- 3. Determine which paths just hit the threshold this round ---
|
||||
TRIGGERED_PATHS=()
|
||||
while IFS= read -r p; do
|
||||
[[ -n "$p" ]] && TRIGGERED_PATHS+=("$p")
|
||||
done < <(jq -r --argjson t "$THRESHOLD" '
|
||||
.counters
|
||||
| to_entries
|
||||
| map(select(.value == $t))
|
||||
| .[].key' "$HISTORY")
|
||||
|
||||
# --- 4. Append events for triggered paths (postmortem trail) ---
|
||||
if [[ "${#TRIGGERED_PATHS[@]}" -gt 0 ]]; then
|
||||
# collect comment ids (best-effort) so the event log carries enough
|
||||
# info to triangulate which findings drove the strike.
|
||||
for path in "${TRIGGERED_PATHS[@]}"; do
|
||||
if [[ -f "$COMMENTS_JSON" ]] \
|
||||
&& jq empty "$COMMENTS_JSON" >/dev/null 2>&1 \
|
||||
&& jq -e 'type == "array"' "$COMMENTS_JSON" >/dev/null 2>&1; then
|
||||
IDS_JSON=$(jq --arg p "$path" '[.[]|select(.path==$p)|.id // empty]' "$COMMENTS_JSON" 2>/dev/null || echo '[]')
|
||||
else
|
||||
IDS_JSON='[]'
|
||||
fi
|
||||
UPDATED=$(jq \
|
||||
--argjson r "$ROUND" \
|
||||
--arg p "$path" \
|
||||
--argjson ids "$IDS_JSON" \
|
||||
'.events += [{round:$r, path:$p, comment_ids:$ids}]' \
|
||||
"$HISTORY")
|
||||
printf '%s\n' "$UPDATED" > "$HISTORY.tmp" && mv "$HISTORY.tmp" "$HISTORY"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- 5. Apply the agent-stuck label if any path triggered ---
|
||||
#
|
||||
# Idempotent: gh label create is no-op when the label exists; gh pr
|
||||
# edit --add-label is no-op when the PR already has it. We swallow
|
||||
# benign "already exists" errors so a transient gh failure doesn't
|
||||
# crash the loop (Rule 2 is monitor-only by design).
|
||||
if [[ "${#TRIGGERED_PATHS[@]}" -gt 0 && -n "$PR" && "$GH_BIN" != "none" && -n "$GH_BIN" ]]; then
|
||||
if ! command -v "$GH_BIN" >/dev/null 2>&1 && [[ ! -x "$GH_BIN" ]]; then
|
||||
echo "round-post.sh: $GH_BIN not found; skipping label application" >&2
|
||||
else
|
||||
"$GH_BIN" label create "$LABEL" \
|
||||
--repo "$REPO" \
|
||||
--description "$LABEL_DESC" \
|
||||
--color "$LABEL_COLOR" \
|
||||
>/dev/null 2>&1 || true
|
||||
"$GH_BIN" pr edit "$PR" \
|
||||
--repo "$REPO" \
|
||||
--add-label "$LABEL" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# round-pre.sh — pre-codex hook for the review loop.
|
||||
#
|
||||
# Implements Rule 1 (same-SHA APPROVE skip): if any prior round in
|
||||
# this loop run already produced an APPROVE on the *current* HEAD
|
||||
# sha, skip the codex invocation entirely. Reuse the prior APPROVE
|
||||
# verbatim by writing a marker round-NN.json that carries the same
|
||||
# codex_event/blockers as the prior round and points back at it
|
||||
# via the "approve_reused_from" field.
|
||||
#
|
||||
# Inputs (env or positional, env wins):
|
||||
# RUN_DIR — review/ root for this loop run; must contain rounds/.
|
||||
# NEXT_ROUND — integer round number about to run (e.g. 5).
|
||||
# HEAD_SHA — full git sha of the PR HEAD this round would review.
|
||||
# LATEST_ISSUE_ID — current max id of any non-reviewer top-level PR
|
||||
# (issue) comment.
|
||||
# LATEST_REVIEW_ID — current max id of any non-reviewer review/inline
|
||||
# comment (incl. thread replies).
|
||||
# Each is independent. When supplied, the reuse
|
||||
# decision requires the prior approving round's
|
||||
# counterpart watermark to match. A caller may
|
||||
# supply only one — the unsupplied dimension is
|
||||
# not constrained. If a NEW human comment of
|
||||
# either kind landed on the same SHA after the
|
||||
# prior APPROVE, the matched watermark mismatches
|
||||
# and this hook returns "proceed" so codex re-
|
||||
# reviews and ingests the comment. When BOTH are
|
||||
# absent or empty, the watermark guard is skipped
|
||||
# entirely (legacy behavior, used by tests that
|
||||
# pre-date the watermark fields).
|
||||
#
|
||||
# Positional fallback:
|
||||
# round-pre.sh <RUN_DIR> <NEXT_ROUND> <HEAD_SHA> [LATEST_ISSUE_ID] [LATEST_REVIEW_ID]
|
||||
#
|
||||
# Output:
|
||||
# - exit 0 + stdout "skip" → loop should skip codex; round-NN.json is
|
||||
# already written by this script.
|
||||
# - exit 0 + stdout "proceed" → no prior APPROVE on this SHA, OR a
|
||||
# recoverable condition was hit (missing
|
||||
# args, missing rounds dir, malformed
|
||||
# prior round file, etc.). The loop's
|
||||
# ``|| echo "proceed"`` fallback expects
|
||||
# this contract: round-pre.sh is a
|
||||
# monitor and MUST NOT exit non-zero on
|
||||
# recoverable conditions, otherwise
|
||||
# corrupt state would be silently
|
||||
# re-mapped to "proceed" and could
|
||||
# re-enable a same-SHA re-review.
|
||||
# - non-zero exit → reserved for truly unexpected errors
|
||||
# (e.g. jq missing). Currently unused.
|
||||
#
|
||||
# Determinism: pure file scan. No gh / git / network. No LLM.
|
||||
set -uo pipefail
|
||||
|
||||
RUN_DIR="${RUN_DIR:-${1:-}}"
|
||||
NEXT_ROUND="${NEXT_ROUND:-${2:-}}"
|
||||
HEAD_SHA="${HEAD_SHA:-${3:-}}"
|
||||
LATEST_ISSUE_ID="${LATEST_ISSUE_ID:-${4:-}}"
|
||||
LATEST_REVIEW_ID="${LATEST_REVIEW_ID:-${5:-}}"
|
||||
|
||||
if [[ -z "$RUN_DIR" || -z "$NEXT_ROUND" || -z "$HEAD_SHA" ]]; then
|
||||
# Missing args is a recoverable misconfiguration: emit "proceed" so the
|
||||
# loop continues with codex rather than failing fast. (See contract
|
||||
# note above.)
|
||||
echo "round-pre.sh: missing args; usage: RUN_DIR=... NEXT_ROUND=... HEAD_SHA=... round-pre.sh" >&2
|
||||
echo "proceed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ROUNDS_DIR="$RUN_DIR/rounds"
|
||||
if [[ ! -d "$ROUNDS_DIR" ]]; then
|
||||
# No prior rounds → nothing to reuse; loop proceeds normally.
|
||||
echo "proceed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Scan prior round-*.json files for an APPROVE on the same head_sha_after.
|
||||
# Pure jq filter; oldest-first sort iterates rounds chronologically.
|
||||
#
|
||||
# Selection contract: when LATEST_ISSUE_ID and/or LATEST_REVIEW_ID are
|
||||
# provided, we MUST iterate ALL same-SHA APPROVE candidates and reuse
|
||||
# the one whose recorded watermarks match every supplied LATEST_*_ID
|
||||
# (per-dimension; unsupplied dimensions are unconstrained). Breaking
|
||||
# on the first SHA match would cause this failure mode:
|
||||
#
|
||||
# round 5: APPROVE on sha=X, issue_wm=100, review_wm=50
|
||||
# <human comments land; watermarks → 200, 60>
|
||||
# round 6: re-APPROVE on sha=X, issue_wm=200, review_wm=60
|
||||
# round 7: must skip, but old code found round 5 first, saw stale
|
||||
# watermark=100 != 200, returned "proceed" — re-running codex
|
||||
# even though round 6 is a perfectly reusable APPROVE.
|
||||
#
|
||||
# So: keep scanning. Each watermark dimension is independent — only the
|
||||
# LATEST_*_ID values the caller actually supplied are required to match;
|
||||
# unsupplied dimensions are unconstrained. If no watermarks are supplied
|
||||
# (or no prior round recorded them) fall back to legacy SHA-only reuse
|
||||
# using the earliest APPROVE.
|
||||
PRIOR_FILE=""
|
||||
PRIOR_ROUND=""
|
||||
PRIOR_BLOCKERS=0
|
||||
PRIOR_ISSUE_ID=""
|
||||
PRIOR_REVIEW_ID=""
|
||||
# Track the earliest same-SHA APPROVE as a fallback for the legacy
|
||||
# (no-watermark) path so pre-watermark round files keep skipping.
|
||||
FALLBACK_FILE=""
|
||||
FALLBACK_ROUND=""
|
||||
FALLBACK_BLOCKERS=0
|
||||
FALLBACK_ISSUE_ID=""
|
||||
FALLBACK_REVIEW_ID=""
|
||||
|
||||
# Use a sorted glob so iteration order is stable across filesystems.
|
||||
# Iterate via while-read against a NUL-safe stream so paths with spaces
|
||||
# or other shell-meta characters survive intact (paths come from the
|
||||
# RUN_DIR caller-supplied root). Malformed JSON in any candidate file is
|
||||
# treated as "not an approval" and skipped silently — corrupt round
|
||||
# files must never crash the monitor; downstream consumers are
|
||||
# responsible for surfacing them.
|
||||
shopt -s nullglob
|
||||
while IFS= read -r -d '' f; do
|
||||
[[ -f "$f" ]] || continue
|
||||
ev=$(jq -r '.codex_event // empty' "$f" 2>/dev/null) || ev=""
|
||||
sha=$(jq -r '.head_sha_after // empty' "$f" 2>/dev/null) || sha=""
|
||||
[[ "$ev" == "APPROVE" && "$sha" == "$HEAD_SHA" ]] || continue
|
||||
|
||||
cand_round=$(jq -r '.round // empty' "$f" 2>/dev/null) || cand_round=""
|
||||
cand_blockers=$(jq -r '.blockers_after // 0' "$f" 2>/dev/null) || cand_blockers=0
|
||||
# Backward-compat: pre-namespace-split round files only carry
|
||||
# last_human_comment_id; treat it as the issue-comment seed (the legacy
|
||||
# max() across both endpoints was dominated by issue ids in practice).
|
||||
cand_issue_id=$(jq -r '.last_issue_comment_id // .last_human_comment_id // empty' "$f" 2>/dev/null) || cand_issue_id=""
|
||||
cand_review_id=$(jq -r '.last_review_comment_id // empty' "$f" 2>/dev/null) || cand_review_id=""
|
||||
# Defensive numeric validation: --argjson rejects non-JSON-numeric
|
||||
# values, which would crash marker generation below. A corrupt prior
|
||||
# round file (e.g. blockers_after: "many", round: null) must not
|
||||
# break the monitor — coerce to safe defaults so the marker still
|
||||
# writes and reuse can proceed. Empty strings from `// empty` above
|
||||
# are normalized here too.
|
||||
[[ "$cand_round" =~ ^[0-9]+$ ]] || cand_round=0
|
||||
[[ "$cand_blockers" =~ ^[0-9]+$ ]] || cand_blockers=0
|
||||
[[ "$cand_issue_id" =~ ^[0-9]+$ ]] || cand_issue_id=""
|
||||
[[ "$cand_review_id" =~ ^[0-9]+$ ]] || cand_review_id=""
|
||||
|
||||
# Remember the earliest same-SHA APPROVE for the legacy fallback.
|
||||
if [[ -z "$FALLBACK_FILE" ]]; then
|
||||
FALLBACK_FILE="$f"
|
||||
FALLBACK_ROUND="$cand_round"
|
||||
FALLBACK_BLOCKERS="$cand_blockers"
|
||||
FALLBACK_ISSUE_ID="$cand_issue_id"
|
||||
FALLBACK_REVIEW_ID="$cand_review_id"
|
||||
fi
|
||||
|
||||
# Watermark-aware reuse: pick this candidate only if every watermark
|
||||
# the caller actually supplied matches its recorded counterpart. A
|
||||
# caller that provides only LATEST_ISSUE_ID does not constrain the
|
||||
# review-id dimension, so we must NOT silently require the recorded
|
||||
# review id to be 0. Continue scanning otherwise so a later same-SHA
|
||||
# APPROVE with fresher watermarks can still win.
|
||||
ok=true
|
||||
if [[ -n "$LATEST_ISSUE_ID" \
|
||||
&& "${cand_issue_id:-0}" != "$LATEST_ISSUE_ID" ]]; then
|
||||
ok=false
|
||||
fi
|
||||
if [[ -n "$LATEST_REVIEW_ID" \
|
||||
&& "${cand_review_id:-0}" != "$LATEST_REVIEW_ID" ]]; then
|
||||
ok=false
|
||||
fi
|
||||
if [[ ( -n "$LATEST_ISSUE_ID" || -n "$LATEST_REVIEW_ID" ) \
|
||||
&& "$ok" == "true" ]]; then
|
||||
PRIOR_FILE="$f"
|
||||
PRIOR_ROUND="$cand_round"
|
||||
PRIOR_BLOCKERS="$cand_blockers"
|
||||
PRIOR_ISSUE_ID="$cand_issue_id"
|
||||
PRIOR_REVIEW_ID="$cand_review_id"
|
||||
break
|
||||
fi
|
||||
done < <(find "$ROUNDS_DIR" -maxdepth 1 -type f -name 'round-*.json' -print0 2>/dev/null | sort -z)
|
||||
|
||||
# Resolve which candidate (if any) wins.
|
||||
if [[ -z "$PRIOR_FILE" ]]; then
|
||||
if [[ -z "$FALLBACK_FILE" ]]; then
|
||||
# No same-SHA APPROVE at all → nothing to reuse.
|
||||
echo "proceed"
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$LATEST_ISSUE_ID" || -n "$LATEST_REVIEW_ID" ]]; then
|
||||
# Caller supplied watermarks and NO same-SHA APPROVE matched them.
|
||||
# That means a fresh human comment (issue or review) has landed
|
||||
# since every prior APPROVE on this SHA — codex must re-review.
|
||||
echo "proceed"
|
||||
exit 0
|
||||
fi
|
||||
# Legacy path: no watermarks from caller → reuse the earliest match.
|
||||
PRIOR_FILE="$FALLBACK_FILE"
|
||||
PRIOR_ROUND="$FALLBACK_ROUND"
|
||||
PRIOR_BLOCKERS="$FALLBACK_BLOCKERS"
|
||||
PRIOR_ISSUE_ID="$FALLBACK_ISSUE_ID"
|
||||
PRIOR_REVIEW_ID="$FALLBACK_REVIEW_ID"
|
||||
fi
|
||||
|
||||
# Found a prior APPROVE on this SHA. Emit a marker round-NN.json that
|
||||
# reuses the prior outcome and clearly notes the reuse so postmortem
|
||||
# tools can distinguish skipped rounds from genuinely-reviewed rounds.
|
||||
N=$(printf '%02d' "$NEXT_ROUND")
|
||||
MARKER="$ROUNDS_DIR/round-$N.json"
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# Carry the watermarks forward so a downstream reuse decision (or
|
||||
# postmortem) can tell whether new human comments have landed since the
|
||||
# *original* approving round. Use the caller-provided LATEST_*_ID when
|
||||
# present; otherwise inherit the prior round's recorded watermark.
|
||||
MARKER_ISSUE_ID="${LATEST_ISSUE_ID:-${PRIOR_ISSUE_ID:-0}}"
|
||||
MARKER_REVIEW_ID="${LATEST_REVIEW_ID:-${PRIOR_REVIEW_ID:-0}}"
|
||||
[[ -z "$MARKER_ISSUE_ID" ]] && MARKER_ISSUE_ID=0
|
||||
[[ -z "$MARKER_REVIEW_ID" ]] && MARKER_REVIEW_ID=0
|
||||
|
||||
# Final numeric validation before --argjson. Any of the values below
|
||||
# could still be non-numeric here (e.g. caller-supplied NEXT_ROUND or
|
||||
# LATEST_*_ID came in malformed). Coerce or bail to "proceed" so the
|
||||
# loop falls back to a real codex run rather than silently emitting
|
||||
# "skip" without writing a marker.
|
||||
[[ "$NEXT_ROUND" =~ ^[0-9]+$ ]] || { echo "round-pre.sh: NEXT_ROUND not numeric ($NEXT_ROUND); proceeding" >&2; echo "proceed"; exit 0; }
|
||||
[[ "$PRIOR_BLOCKERS" =~ ^[0-9]+$ ]] || PRIOR_BLOCKERS=0
|
||||
[[ "$PRIOR_ROUND" =~ ^[0-9]+$ ]] || PRIOR_ROUND=0
|
||||
[[ "$MARKER_ISSUE_ID" =~ ^[0-9]+$ ]] || MARKER_ISSUE_ID=0
|
||||
[[ "$MARKER_REVIEW_ID" =~ ^[0-9]+$ ]] || MARKER_REVIEW_ID=0
|
||||
|
||||
if ! jq -n \
|
||||
--argjson r "$NEXT_ROUND" \
|
||||
--arg now "$NOW" \
|
||||
--arg sha "$HEAD_SHA" \
|
||||
--arg ev "APPROVE" \
|
||||
--argjson bl "$PRIOR_BLOCKERS" \
|
||||
--argjson prior_round "$PRIOR_ROUND" \
|
||||
--argjson iid "$MARKER_ISSUE_ID" \
|
||||
--argjson rid "$MARKER_REVIEW_ID" \
|
||||
'{round:$r, finished_at:$now,
|
||||
head_sha_before:$sha, head_sha_after:$sha,
|
||||
codex_event:$ev, blockers_after:$bl,
|
||||
last_issue_comment_id:$iid,
|
||||
last_review_comment_id:$rid,
|
||||
approve_reused_from: $prior_round,
|
||||
skipped_codex: true}' \
|
||||
> "$MARKER" 2>/dev/null; then
|
||||
# Marker generation failed (jq error, disk full, etc.). Without a
|
||||
# marker we MUST NOT emit "skip" — the loop would skip codex *and*
|
||||
# have no recorded round file, leaving the postmortem trail broken.
|
||||
# Force a real codex re-run instead.
|
||||
rm -f "$MARKER" 2>/dev/null || true
|
||||
echo "round-pre.sh: marker generation failed; forcing codex re-run" >&2
|
||||
echo "proceed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Defensive: if jq exited 0 but the marker is missing or empty, also
|
||||
# fall back to "proceed" (e.g. redirection failure on a read-only FS).
|
||||
if [[ ! -s "$MARKER" ]]; then
|
||||
rm -f "$MARKER" 2>/dev/null || true
|
||||
echo "round-pre.sh: marker missing/empty after jq; forcing codex re-run" >&2
|
||||
echo "proceed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "skip"
|
||||
exit 0
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
# signals.sh — pure helpers that compute the per-poll "fresh round" signal.
|
||||
# Sourced, never executed; no shebang on purpose.
|
||||
#
|
||||
# The review loop fires a fresh round whenever the PR's externally
|
||||
# observable state has materially changed. Sourced by `loop.sh`.
|
||||
# Pure: no gh / git / network calls; callers pass the raw GitHub
|
||||
# fields in.
|
||||
#
|
||||
# Public functions:
|
||||
#
|
||||
# sha256_text <text>
|
||||
# Print the lowercase sha256 of <text> (truncated to 16 hex chars to
|
||||
# keep meta.json compact). Single-line output, no newline-terminated
|
||||
# trailing bytes via printf '%s'.
|
||||
#
|
||||
# pr_body_hash <body>
|
||||
# Stable hash of a PR body. Normalizes CRLF to LF so a body edited on
|
||||
# GitHub web (CRLF) and locally (LF) compares equal when textually
|
||||
# identical. Empty body → fixed sentinel hash of empty string.
|
||||
#
|
||||
# pr_labels_hash <labels_json_array>
|
||||
# Stable hash of a labels set. <labels_json_array> is the raw
|
||||
# `.labels` field from `gh pr view --json labels` — a JSON array of
|
||||
# `{name,...}` objects. Hash is order-independent (labels are a set,
|
||||
# not a list).
|
||||
#
|
||||
# signature_diff_reason \
|
||||
# <head_now> <head_prev> \
|
||||
# <body_now> <body_prev> \
|
||||
# <labels_now> <labels_prev> \
|
||||
# <issue_id_now> <issue_id_prev> \
|
||||
# <review_id_now> <review_id_prev> \
|
||||
# <inbox_present>
|
||||
# Print a single short reason string naming which signal fired, or
|
||||
# the empty string if no signal changed. Reasons are stable tokens
|
||||
# (used in log lines and asserted by tests):
|
||||
# - "head changed"
|
||||
# - "body changed"
|
||||
# - "labels changed"
|
||||
# - "issue comment"
|
||||
# - "review comment"
|
||||
# - "inbox prompt"
|
||||
# When multiple signals change in one tick, the first one in the
|
||||
# priority order above wins — every signal still triggers exactly
|
||||
# one round, so picking one stable token avoids double-firing.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
sha256_text() {
|
||||
# printf '%s' avoids the implicit trailing newline of echo which would
|
||||
# otherwise make `sha256_text ""` differ from a true-empty hash.
|
||||
# Prefer GNU `sha256sum`; fall back to BSD/macOS `shasum -a 256`.
|
||||
local out
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
out=$(printf '%s' "$1" | sha256sum)
|
||||
else
|
||||
out=$(printf '%s' "$1" | shasum -a 256)
|
||||
fi
|
||||
printf '%s' "$out" | cut -c1-16
|
||||
}
|
||||
|
||||
pr_body_hash() {
|
||||
local body="${1-}"
|
||||
# Normalize CRLF → LF so web-edit and CLI-edit bodies compare equal.
|
||||
body="${body//$'\r'/}"
|
||||
sha256_text "$body"
|
||||
}
|
||||
|
||||
pr_labels_hash() {
|
||||
local labels_json="${1:-[]}"
|
||||
# Sort label names so the hash is order-independent. Serialize as
|
||||
# compact JSON (not join(",")) so a label name containing a comma
|
||||
# cannot collide with two labels split on commas. `jq -e` would exit
|
||||
# non-zero on empty input; default to "[]" so the empty-labels case
|
||||
# yields a stable, deterministic hash.
|
||||
#
|
||||
# ``agent-stuck`` is loop-owned (round-post.sh applies it); excluding
|
||||
# it keeps the loop from observing its own write as a fresh label
|
||||
# signal on the next poll.
|
||||
local sorted
|
||||
sorted=$(printf '%s' "$labels_json" \
|
||||
| jq -c 'if type=="array" then [.[].name | select(. != "agent-stuck")] | sort else [] end' \
|
||||
2>/dev/null) || sorted="[]"
|
||||
sha256_text "$sorted"
|
||||
}
|
||||
|
||||
signature_diff_reason() {
|
||||
local head_now="$1" head_prev="$2"
|
||||
local body_now="$3" body_prev="$4"
|
||||
local labels_now="$5" labels_prev="$6"
|
||||
local issue_now="$7" issue_prev="$8"
|
||||
local review_now="$9" review_prev="${10}"
|
||||
local inbox_present="${11:-0}"
|
||||
|
||||
if [[ "$head_now" != "$head_prev" ]]; then
|
||||
printf '%s' "head changed"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$body_now" != "$body_prev" && -n "$body_prev" ]]; then
|
||||
printf '%s' "body changed"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$labels_now" != "$labels_prev" && -n "$labels_prev" ]]; then
|
||||
printf '%s' "labels changed"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$issue_now" != "$issue_prev" ]]; then
|
||||
printf '%s' "issue comment"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$review_now" != "$review_prev" ]]; then
|
||||
printf '%s' "review comment"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$inbox_present" -eq 1 ]]; then
|
||||
printf '%s' "inbox prompt"
|
||||
return 0
|
||||
fi
|
||||
printf '%s' ""
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
---
|
||||
name: scaffold-op
|
||||
description: Scaffold a new T2 (L1-direct) Op file from a single `tileops/manifest/` entry by following the 7-step playbook in docs/design/ops-design.md. Emits the 17 scaffold slots (S1-S7, S12-S21); leaves family-specific protocol variables, optional hooks, and kernel implementations to downstream skills.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
`op_name` (positional) — manifest key for the op to scaffold, equal to the target `cls.__name__` (e.g. `CumsumFwdOp`).
|
||||
|
||||
## Contract
|
||||
|
||||
- **Input**: `op_name` must be present in [`tileops/manifest/`](../../../tileops/manifest/) with `status: spec-only` and a non-empty `source.kernel_map`. `source.kernel_map` is manifest-level source of truth for Op→Kernel dispatch and cannot be derived by the scaffold (dispatch keys are kernel-internal conventions); adding it for a spec-only entry is a prerequisite manifest PR.
|
||||
- **Output**: new file at the exact path declared by manifest `source.op` (e.g., `tileops/ops/reduction/cumsum.py`), containing the 17 scaffold slots; one-line `from .<module> import <ClassName>` added to the package `__init__.py` at that path's parent directory (e.g., `tileops/ops/reduction/__init__.py`) with a matching `__all__` entry. Note: the filesystem package directory (parent of `source.op`) is not always the same as the manifest `family` field — for example, `CumsumFwdOp` has `family: scan` but lives under `tileops/ops/reduction/`. Always key paths off `source.op`, never off `family`. Plus a side-artefact at `.foundry/plan/<op_name>/plan.json` carrying the DRY_RUN self-audit (not tracked in git).
|
||||
- **Termination (success)**: `python scripts/validate_manifest.py --check-op <op_name>` reports **no errors** for this op. Warnings are allowed and passed through to the final summary.
|
||||
- **Termination (blocked)**: any validator error for `op_name` that the scaffold cannot fix by re-reading the playbook's slot rules. Do NOT commit; report with the failing rows from the validator.
|
||||
- **Constraints**:
|
||||
- MUST NOT emit family-specific protocol variables (`_op_kind`, `_kernel_key`, `_kernel_cls`, `_kernel_handles_padding`, `_op_name`, `kernel_cls`).
|
||||
- MUST NOT emit optional hooks (`_pad_value`, `_validate_dim`, `_pre_kernel`, `_post_kernel`, `_cache_key` override).
|
||||
- MUST NOT implement the kernel itself.
|
||||
- MUST NOT modify `tileops/manifest/`, tests, benchmarks, or any existing op file.
|
||||
- MUST NOT extend scope to a T1 (family-base) subclass — the scaffold is T2 only.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> READ
|
||||
READ --> PRE_CHECK
|
||||
PRE_CHECK --> DRY_RUN
|
||||
PRE_CHECK --> BLOCKED: precondition failed
|
||||
DRY_RUN --> EMIT: plan.json written
|
||||
EMIT --> REGISTER
|
||||
REGISTER --> VALIDATE
|
||||
VALIDATE --> REPORT
|
||||
VALIDATE --> BLOCKED: validator error or §1 drift
|
||||
REPORT --> [*]
|
||||
BLOCKED --> [*]
|
||||
```
|
||||
|
||||
`DRY_RUN` writes `plan.json` to freeze manifest-sourced facts before codegen. `VALIDATE` diffs the emitted file against `plan.json` §1 — any drift is a skill bug, not a manifest issue.
|
||||
|
||||
## Slot scope
|
||||
|
||||
Emit exactly the 17 slots in [`ops-design-reference.md § Slot Rules`](../../../docs/design/ops-design-reference.md#slot-rules): S1–S7, S12–S21. S8–S11 are reserved for T1 thin-wrapper subclasses and skipped.
|
||||
|
||||
Out of scope — leave empty:
|
||||
|
||||
| Item | Reason |
|
||||
| ----------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| Family protocol vars (`_op_kind`, `_kernel_key`, `_op_name`, …) | Kernel-dispatch convention; not in manifest |
|
||||
| Optional hooks (`_pad_value`, `_validate_dim`, `_pre_kernel`, `_post_kernel`) | Op-specific business logic |
|
||||
| `_cache_key` override | Recommended under dynamic shapes; depends on kernel math |
|
||||
| Kernel implementations | Owned by kernel skill |
|
||||
| Tests / benchmarks | Owned by `test-op` / `bench-op` |
|
||||
|
||||
These gaps surface as `NotImplementedError` or validator warnings; downstream skills fill them.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. READ
|
||||
|
||||
Load the manifest entry for `op_name`:
|
||||
|
||||
Before running the snippet, substitute `<op_name>` with the requested manifest key (the skill's positional argument — agent literal substitution, not shell interpolation):
|
||||
|
||||
```bash
|
||||
python - "<op_name>" <<'PY'
|
||||
import sys
|
||||
from tileops.manifest import load_manifest
|
||||
|
||||
op_name = sys.argv[1]
|
||||
entry = load_manifest()[op_name]
|
||||
print(entry)
|
||||
PY
|
||||
```
|
||||
|
||||
Extract: `family`, `status`, `signature.inputs`, `signature.outputs`, `signature.params`, `signature.static_dims`, `signature.shape_rules`, `source.kernel_map`, `source.op`, `source.kernel`, `roofline.vars`, `roofline.flops`, `roofline.bytes`.
|
||||
|
||||
Derive the target file path from `source.op` (e.g. `tileops/ops/reduction/cumsum.py`). The **filesystem package directory** is `source.op`'s parent (e.g. `tileops/ops/reduction/`). Do not use the manifest `family` field to compute paths — it is a semantic label, and some ops have `family` distinct from their filesystem parent (e.g., `CumsumFwdOp` has `family: scan` but lives under `reduction/`). Module filename is `source.op`'s basename without `.py`.
|
||||
|
||||
### 2. PRE_CHECK
|
||||
|
||||
- `op_name` present in `tileops/manifest/` → proceed; otherwise BLOCKED ("op not in manifest").
|
||||
- `status` field explicitly set to `spec-only` → proceed; `status: implemented` → BLOCKED ("op already implemented; use implement-op to migrate"); missing `status` or any other value → BLOCKED ("manifest entry must declare a valid top-level `status`; the validator treats `status` as required").
|
||||
- `source.kernel_map` declared and non-empty → proceed; missing or empty → BLOCKED ("manifest entry needs `source.kernel_map` before scaffolding — add the dispatch map in a separate manifest PR per the trust model; the scaffold cannot invent dispatch keys because they are kernel-internal conventions"). Note: per `docs/design/manifest.md`, `source.kernel_map` is only required when `status: implemented`, so many existing `spec-only` entries lack it — these are the cases that need the manifest-PR prerequisite before scaffolding can run.
|
||||
- Every value in `source.kernel_map` resolves to an importable symbol → proceed; otherwise BLOCKED ("kernel class not found at expected path").
|
||||
- Target file `source.op` does NOT exist → proceed; exists → BLOCKED ("target file already present; scaffold would overwrite").
|
||||
|
||||
BLOCKED terminations return without writing any file.
|
||||
|
||||
### 3. DRY_RUN
|
||||
|
||||
Write `.foundry/plan/<op_name>/plan.json` with three sections:
|
||||
|
||||
| Section | Diffed at VALIDATE? | Content |
|
||||
| --------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `locked_facts` (§1) | yes (hard error on drift) | Verbatim manifest extraction (op_name, class_name, family, module_path, kernel_imports, kernel_map, init_kwargs, forward_inputs/outputs, dtype_unions, dtype_combos, shape_rules, static_dims, roofline) |
|
||||
| `agent_notes` (§2) | no | Judgement calls (docstring, kernel ctor signature observed, helper state, forward reshape strategy, codebase refs consulted) |
|
||||
| `open_questions` (§3) | no | Ambiguities tagged `needs_doc_fix` / `needs_manifest_fix` / `needs_human_decision`; surfaced in REPORT, never block |
|
||||
|
||||
Always proceed to EMIT. Empty `open_questions` is fine.
|
||||
|
||||
Skeleton:
|
||||
|
||||
```json
|
||||
{
|
||||
"locked_facts": {
|
||||
"op_name": "CumsumFwdOp",
|
||||
"module_path": "tileops/ops/reduction/cumsum.py",
|
||||
"kernel_map": {"cumulative_fwd": "CumulativeKernel"},
|
||||
"init_kwargs": [{"name": "dim", "source": "signature.params.dim", "type": "int", "default": -1}],
|
||||
"forward_inputs": ["x"],
|
||||
"static_dims": {"N": "x.shape[dim]"},
|
||||
"roofline": {"flops": "M * N", "bytes": "2 * M * N * elem_bytes"}
|
||||
},
|
||||
"agent_notes": {"docstring_summary": "...", "kernel_ctor_signature_observed": "..."},
|
||||
"open_questions": [{"tag": "needs_human_decision", "topic": "...", "detail": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. EMIT
|
||||
|
||||
Follow [`docs/design/ops-design.md` § Scaffolding an Op from a Manifest Entry](../../../docs/design/ops-design.md#scaffolding-an-op-from-a-manifest-entry) Steps 1-7 in order. For each scaffold slot, read the authoritative rule at `docs/design/ops-design-reference.md#slot-sN` before emitting.
|
||||
|
||||
Key slot pointers (follow the reference, do not re-derive):
|
||||
|
||||
| Playbook step | Slots | Reference anchor |
|
||||
| ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Step 1 | S1, S2, S3, S4 | [S1](../../../docs/design/ops-design-reference.md#slot-s1)-[S4](../../../docs/design/ops-design-reference.md#slot-s4) |
|
||||
| Step 2 | S5, S6, S7 | [S5](../../../docs/design/ops-design-reference.md#slot-s5)-[S7](../../../docs/design/ops-design-reference.md#slot-s7) |
|
||||
| Step 3 | S21, S12, S13 | [S21](../../../docs/design/ops-design-reference.md#slot-s21), [S12](../../../docs/design/ops-design-reference.md#slot-s12), [S13](../../../docs/design/ops-design-reference.md#slot-s13) |
|
||||
| Step 4 | S14, S15, S16 | [S14](../../../docs/design/ops-design-reference.md#slot-s14)-[S16](../../../docs/design/ops-design-reference.md#slot-s16) |
|
||||
| Step 5 | S17, S18 | [S17](../../../docs/design/ops-design-reference.md#slot-s17), [S18](../../../docs/design/ops-design-reference.md#slot-s18) |
|
||||
| Step 6 | S19 | [S19](../../../docs/design/ops-design-reference.md#slot-s19) |
|
||||
| Step 7 | S20 | [S20](../../../docs/design/ops-design-reference.md#slot-s20) |
|
||||
|
||||
If a slot's rule is ambiguous for the given manifest entry (e.g. multi-kernel `kernel_map`, multiple independent dtype axes, fixed-rank vs arbitrary-rank branching), STOP and surface the ambiguity in the final report instead of guessing. Do not expand scope.
|
||||
|
||||
### 5. REGISTER
|
||||
|
||||
Append to the `__init__.py` at `dirname(source.op)`:
|
||||
|
||||
| File style | Action |
|
||||
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Grouping comments `# --- <KernelClassName> ops ---` (e.g. `tileops/ops/reduction/__init__.py`) | Place import under matching kernel block; create a new block (alphabetical) when no existing block references the kernel |
|
||||
| Flat imports (e.g. `tileops/ops/norm/__init__.py`) | Append alongside existing imports following file's ordering convention; do NOT introduce grouping comments |
|
||||
|
||||
Add matching `<ClassName>` to `__all__`, preserving any existing sectioning.
|
||||
|
||||
### 6. VALIDATE
|
||||
|
||||
**(a) §1 post-check** — diff `plan.json.locked_facts` against the emitted artefacts:
|
||||
|
||||
- Parse `source.op` with `ast`: extract class name, base, imports, `__all__`, `__init__` kwargs (names / defaults / types in order), `forward` params, `default_kernel_map` dict, `_static_axes`.
|
||||
- Parse `dirname(source.op)/__init__.py` with both `ast` (presence + `__all__` membership) and raw text (block placement under `# --- <KernelClassName> ops ---` when the file uses grouping comments; skip placement check on flat-style files).
|
||||
- Any `locked_facts` field mismatch → BLOCKED `§1 drift: <field>`. Skill deviated from its own contract; fix the emitted file (or revert and restart DRY_RUN if the plan was wrong). Do NOT edit `plan.json` to match.
|
||||
|
||||
**(b) Manifest validator**:
|
||||
|
||||
```bash
|
||||
python scripts/validate_manifest.py --check-op <op_name>
|
||||
```
|
||||
|
||||
`--check-op` runs L0–L4 even on `status: spec-only`. Classify output:
|
||||
|
||||
- **ERROR** → BLOCKED. Copy the row to REPORT. For L2 / L3 parity failures, re-check the emitted `_infer_output_shapes` / `_validate_dtypes` against manifest `shape_rules` / `dtype_combos` first — likely a mis-emit.
|
||||
- **WARNING** → pass through.
|
||||
|
||||
Never edit the manifest to silence an error. No `parity_opt_out` escape hatch. Demote to `status: spec-only` only when the implementation genuinely cannot conform.
|
||||
|
||||
### 7. REPORT
|
||||
|
||||
```
|
||||
Status: SUCCESS | BLOCKED
|
||||
Op: <op_name>
|
||||
File: <path> (<lines>)
|
||||
Package registration: <parent-of-source.op>/__init__.py (+1 import, +1 __all__)
|
||||
Plan: .foundry/plan/<op_name>/plan.json
|
||||
|
||||
§1 drift: <none | <field>: <plan> vs <emitted>>
|
||||
Validator: <N errors, M warnings>
|
||||
<if BLOCKED, list each blocking error row verbatim>
|
||||
|
||||
Warnings:
|
||||
- <warning>
|
||||
|
||||
Open questions (from plan.json §3):
|
||||
needs_doc_fix: - <topic>: <detail>
|
||||
needs_manifest_fix: - <topic>: <detail>
|
||||
needs_human_decision: - <topic>: <detail>
|
||||
|
||||
Not filled (downstream hand-off):
|
||||
- Kernel implementation
|
||||
- Optional hooks (family-specific)
|
||||
- Family protocol variables (family-specific)
|
||||
- _cache_key override (recommended under dynamic shapes)
|
||||
```
|
||||
|
||||
SUCCESS → commit `[Feat][OPS] scaffold <op_name>`. BLOCKED → leave tree dirty, return. `plan.json` persists either way.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not a codegen engine — agent procedure. The input/output contract is codegen-compatible.
|
||||
- Not a migration driver — `status: implemented` ops use `implement-op` / `align-family`.
|
||||
- Not a kernel scaffold.
|
||||
|
||||
If a slot rule is internally inconsistent or cannot be followed mechanically, file a doc issue and BLOCK; do not invent slot behaviour.
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
name: test-op
|
||||
description: Write tests for the target spec using PyTorch as ground truth, verify they fail on current code.
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
`op_name`, `manifest_signature`, `pytorch_equivalent`, `source_test` — passed by align-family orchestrator.
|
||||
|
||||
## Contract
|
||||
|
||||
- **Input**: `op_name`, `manifest_signature`, `pytorch_equivalent`, `source_test`
|
||||
- **Output**: updated test file + commit
|
||||
- **Constraint**: must NOT modify op implementation. Test-only.
|
||||
- **Trust model**: this agent must be a different invocation from implement-op.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> READ_SPEC
|
||||
READ_SPEC --> ASSESS_EXISTING: manifest signature + pytorch_equivalent loaded
|
||||
ASSESS_EXISTING --> WRITE_TESTS_EXTEND: semantic extension — old assertions still valid
|
||||
ASSESS_EXISTING --> WRITE_TESTS_REPLACE: semantic update — old assertions incompatible
|
||||
WRITE_TESTS_EXTEND --> VERIFY_FAILS: new tests added, existing kept
|
||||
WRITE_TESTS_REPLACE --> VERIFY_FAILS: outdated tests deleted, new tests written
|
||||
VERIFY_FAILS --> DONE: confirmed failing on current code
|
||||
VERIFY_FAILS --> DONE_SKIP: tests already pass (base class fixed by previous op)
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. READ_SPEC
|
||||
|
||||
Read `manifest_signature` to determine target interface:
|
||||
|
||||
- `signature.inputs` → `forward()` params (tensor inputs)
|
||||
- `signature.params` → `__init__()` params (configuration)
|
||||
- This follows Op design convention in `docs/design/ops-design.md`. The manifest is the source of truth.
|
||||
|
||||
### 2. ASSESS_EXISTING
|
||||
|
||||
Read current test file (`source_test`). Compare existing test construction and assertions against the new spec:
|
||||
|
||||
- Old assertions still valid under new spec → **semantic extension** (keep existing tests, add new ones)
|
||||
- Old assertions incompatible (e.g., construction API changes from `Op(M, N)` to `Op(dim)`) → **semantic update** (delete outdated tests, write replacements)
|
||||
|
||||
### 3. WRITE_TESTS
|
||||
|
||||
Write tests using PyTorch reference as ground truth:
|
||||
|
||||
```python
|
||||
# Example — derive from manifest, don't copy this literally
|
||||
expected = torch.nn.functional.softmax(x, dim=dim)
|
||||
actual = op(x)
|
||||
torch.testing.assert_close(actual, expected, rtol=rtol, atol=atol)
|
||||
```
|
||||
|
||||
- Use `TestBase` pattern (`gen_inputs()` + `ref_program()` + `check()`). Follow `docs/design/testing.md`.
|
||||
- Write tests in `source_test` file. No new files.
|
||||
- For integer outputs (manifest `outputs.*.dtype` is int type), use `torch.equal` for exact comparison.
|
||||
- Parameterize: supported dtypes (FP16, BF16), representative dim values, keepdim True/False where applicable.
|
||||
|
||||
### 4. VERIFY_FAILS
|
||||
|
||||
Run the new tests against current code:
|
||||
|
||||
```bash
|
||||
python -m pytest <source_test> -v
|
||||
```
|
||||
|
||||
New tests must **fail** on current code. Construction-time error counts (e.g., current `__init__` doesn't accept `dim`).
|
||||
|
||||
**DONE_SKIP**: if tests already pass (base class fixed by a previous op's migration), this is valid. Proceed to implement-op.
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"scripts": {
|
||||
"lint": "pre-commit run --all-files"
|
||||
},
|
||||
"pr": {
|
||||
"ai_reviews": [
|
||||
{"name": "gemini", "kind": "comment", "body": "/gemini review"},
|
||||
{"name": "copilot", "kind": "copilot"}
|
||||
]
|
||||
},
|
||||
"knowledge": {
|
||||
"creating_issue": ["docs/design/trust-model.md"],
|
||||
"planning_issue": ["docs/design/trust-model.md"],
|
||||
"developer": null,
|
||||
"reviewer": [
|
||||
".claude/rules/manifest-trust-model.md",
|
||||
".claude/rules/code-style.md",
|
||||
".claude/rules/security.md",
|
||||
".claude/domain-rules/manifest-spec.md",
|
||||
".claude/domain-rules/manifest-validator.md",
|
||||
".claude/domain-rules/ops-design.md",
|
||||
".claude/domain-rules/benchmark.md",
|
||||
".claude/domain-rules/testing-budget.md",
|
||||
".claude/domain-rules/design-docs.md",
|
||||
"docs/design/trust-model.md"
|
||||
],
|
||||
"gatekeeper": [
|
||||
".claude/rules/manifest-trust-model.md",
|
||||
".claude/rules/security.md",
|
||||
"docs/design/trust-model.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<!--
|
||||
INSTRUCTIONS FOR THE AGENT (do not copy into the PR body).
|
||||
|
||||
Layout principle: one section per op, one table per section, TileOPs and
|
||||
baseline side-by-side on the same row so readers can compare without
|
||||
mentally joining two tables.
|
||||
|
||||
Filling in the template below:
|
||||
|
||||
- One row per measurement (shape × dtype) within an op's table.
|
||||
- Baseline column header names the baseline (`torch (ms)`, `FA3 (ms)`,
|
||||
`triton (ms)`). Don't write a generic "Baseline". Multiple baselines →
|
||||
add more columns and more Speedup columns (`vs torch`, `vs FA3`).
|
||||
- Speedup is always present — it's the first number readers look for.
|
||||
Format `4.96×`, two decimals, computed as baseline_ms / tileops_ms.
|
||||
- Throughput column: show ONE — TFLOPS for compute-bound ops (matmul,
|
||||
attention), BW (TB/s) for memory-bound ops (reductions, elementwise,
|
||||
norms). Pure data movement → BW only. Don't list both.
|
||||
- Show throughput for TileOPs only; baseline's absolute throughput is
|
||||
noise once Speedup is given.
|
||||
- Drop the Shape column if the op only varies dtype; in that case put
|
||||
the fixed shape in the section header (e.g. `### {OpName} (4096, 4096)`)
|
||||
so the benchmark's scale stays visible. Never put autotune config
|
||||
(`block_m`, `threads`) in the table — implementation detail.
|
||||
- **Preserve the original shape tuple — never flatten to a single
|
||||
element count.** Write `(4096, 4096)` for a 2D input, `(2, 4096, 128)`
|
||||
for a 3D one, and `(4194304,)` (or `(4M,)`) for a genuinely 1D op.
|
||||
A bare `4M` loses dimensionality: readers can't tell `(4M,)` from
|
||||
`(2K, 2K)` from `(64, 64K)`, yet those have very different access
|
||||
patterns. Use `K`/`M` only inside a tuple to keep large numbers
|
||||
readable, never to replace it.
|
||||
- Environment block goes once at the top, not per op.
|
||||
- Takeaways = conclusions, not data repetition. Wins, losses with a brief
|
||||
reason (not blocking), dtype/shape patterns.
|
||||
|
||||
Bench-file authoring rules (apply when writing benchmarks/ops/*.py, NOT
|
||||
copied into the PR body):
|
||||
|
||||
- All dtypes in `SUPPORTED_DTYPES`; ≥3 shapes per op, include non-pow2
|
||||
if supported.
|
||||
- Shapes must map to real model geometry. Default LLaMA sizes:
|
||||
hidden ∈ {4096, 5120, 8192}, intermediate ∈ {10240, 11008, 14336,
|
||||
20480, 28672}, seq_len ∈ {2048, 4096}.
|
||||
- Every benchmark must record at least one baseline. Tags: `tileops`
|
||||
(TileOPs implementation; required exactly once per config; variants
|
||||
like `tileops-lut` also count) and `torch` / `FA3` / `fla` / `triton`
|
||||
(baselines for comparison). External baselines may be conditional,
|
||||
but the else branch must fall back to torch — never silently skip.
|
||||
- `BenchmarkReport.record()` first arg must be the Op object, never a
|
||||
string literal.
|
||||
-->
|
||||
|
||||
## Benchmark
|
||||
|
||||
**Environment**: \{GPU}, CUDA \{ver}, PyTorch \{ver}, TileLang \{ver}
|
||||
|
||||
### \{OpName}
|
||||
|
||||
| Shape | dtype | TileOPs (ms) | \{baseline} (ms) | Speedup | BW (TB/s) |
|
||||
| ----- | ----- | ------------ | ---------------- | ------- | --------- |
|
||||
|
||||
<!-- Repeat one ### section per op. Drop the Shape column if the op only
|
||||
varies dtype. Swap `BW (TB/s)` for `TFLOPS` on compute-bound ops.
|
||||
Shape values are tuples: `(4096, 4096)`, `(2, 4096, 128)`, `(4M,)`. -->
|
||||
|
||||
**Takeaways:** {wins · losses with brief reason · dtype/shape patterns}
|
||||
|
||||
**Command:** `PYTHONPATH="$PWD" python -m pytest benchmarks/ops/bench_{op}.py -v`
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
# Issue Body Sections
|
||||
|
||||
Structural contract for the issue body — the template and per-section rules below are what the pipeline parses. Constraints authoring policy (work-shape declarations, defaults) lives in [docs/design/trust-model.md §Issue-authoring](../../docs/design/trust-model.md#issue-authoring-declaring-scope).
|
||||
|
||||
## 1. Template
|
||||
|
||||
Copy verbatim. Replace each `{...}`. Keep all five top-level sections.
|
||||
|
||||
```markdown
|
||||
## Description
|
||||
|
||||
### Symptom / Motivation
|
||||
{what is observed or motivates the change}
|
||||
|
||||
### Root Cause Analysis
|
||||
{file paths, logic errors, missing features — "N/A" for feature requests}
|
||||
|
||||
### Related Files
|
||||
{key files, functions, or configs}
|
||||
|
||||
## Goal
|
||||
{concrete objective}
|
||||
|
||||
## Plan
|
||||
<!-- type: {proposal | fixed} -->
|
||||
1. {at least one step}
|
||||
|
||||
## Constraints
|
||||
- {one bullet per constraint or scope declaration — see trust-model.md §Issue-authoring for the three work-shape forms}
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Modified files pass unit tests
|
||||
- [ ] {additional criteria as needed}
|
||||
```
|
||||
|
||||
## 2. Per-section rules
|
||||
|
||||
| Section | Required form |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Description | Three subsections (`Symptom / Motivation`, `Root Cause Analysis`, `Related Files`), each non-empty |
|
||||
| Goal | Non-empty single-paragraph objective |
|
||||
| Plan | `<!-- type: -->` comment (value `proposal` or `fixed`) plus at least one step (`- ` or `1.`) |
|
||||
| Constraints | At least one list item (`- `) |
|
||||
| Acceptance Criteria | At least one checkbox, including `- [ ] Modified files pass unit tests` |
|
||||
|
||||
## 3. Cross-references
|
||||
|
||||
- Constraints authoring policy: [docs/design/trust-model.md §Issue-authoring](../../docs/design/trust-model.md#issue-authoring-declaring-scope)
|
||||
- Reviewer-side criteria: [.claude/review-checklists/pre-review.md](../../.claude/review-checklists/pre-review.md)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<!--
|
||||
KEEP IT SHORT. Reviewers read PRs to learn WHAT changed and HOW to verify it.
|
||||
- Summary: 3–5 bullets, one line each. No prose paragraphs.
|
||||
- Do NOT recount the development process (reviewer rounds, follow-up issues filed,
|
||||
prior commits reverted, design rationale that already lives in the linked issue).
|
||||
- Do NOT restate the linked issue's motivation; "Closes #N" already links it.
|
||||
- One short paragraph of cross-cutting context is fine when bullets can't carry it.
|
||||
- If a section doesn't apply, delete the header — never leave empty sections.
|
||||
-->
|
||||
|
||||
Closes #\{issue-number}
|
||||
|
||||
## Summary
|
||||
|
||||
- {what was added/fixed/changed}
|
||||
- {what was removed/replaced}
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] pre-commit passed
|
||||
- [x] pytest passed
|
||||
|
||||
<!-- Delete inapplicable optional sections entirely. Never leave empty headers. -->
|
||||
|
||||
## Benchmark
|
||||
|
||||
<!-- Required for kernel/op changes. Format: benchmark-template.md -->
|
||||
|
||||
## Regression
|
||||
|
||||
<!-- Recommended for bugfix/refactor -->
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# Pre-Create Checks
|
||||
|
||||
## 1. PR title pre-flight validation
|
||||
|
||||
**HARD GATE.** Validate the PR title locally against the same source of truth that CI uses, **before** calling `gh pr create`. This guarantees the `validate-pr-title` required check cannot fail.
|
||||
|
||||
```bash
|
||||
source .claude/conventions/types.sh
|
||||
TITLE="[{{Type}}] {{Description}}" # substitute your actual title
|
||||
if [[ ! "$TITLE" =~ $COMMIT_MSG_PATTERN ]]; then
|
||||
echo "BLOCKED: title does not match CI pattern: $TITLE"
|
||||
echo "Pattern: $COMMIT_MSG_PATTERN"
|
||||
fi
|
||||
```
|
||||
|
||||
**If validation fails:** fix the title and re-validate. Do NOT proceed to `gh pr create`.
|
||||
|
||||
## 2. Test node delta
|
||||
|
||||
**Skip entirely** if PR does not modify files under `tests/`.
|
||||
|
||||
If PR adds or modifies test files:
|
||||
|
||||
```bash
|
||||
git fetch upstream main --quiet
|
||||
python scripts/test_node_delta.py --base upstream/main
|
||||
```
|
||||
|
||||
The script auto-detects changed test files via `git diff`. If auto-detect fails (e.g. in a worktree), pass files explicitly:
|
||||
|
||||
```bash
|
||||
python scripts/test_node_delta.py --base upstream/main tests/ops/test_<name>.py
|
||||
```
|
||||
|
||||
**Interpreting output:**
|
||||
|
||||
- **No growth on existing files** → nothing to report.
|
||||
- **Growth on existing files** → include script output and one-line justification in PR body under `## Test node delta`.
|
||||
- **New test files only** (delta shows "all N nodes from new files") → no delta report needed.
|
||||
|
||||
**SOFT GATE:** Does not block PR creation, but missing justification will be flagged during review (per [`.claude/domain-rules/testing-budget.md`](../../.claude/domain-rules/testing-budget.md)).
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# Issue Title Format
|
||||
|
||||
**Format:** `[TYPE][COMPONENT] short description in lowercase`
|
||||
|
||||
- **TYPE**: FEAT | BUG | PERF | REFACTOR | DOCS | TEST | META | BENCHMARK — canonical list in `.claude/conventions/types.sh`
|
||||
- **COMPONENT**: mandatory — kernel name or subsystem (GEMV, GEMM, FLASH_ATTN, CI, TOOLING, …)
|
||||
- Max 80 characters total, description in lowercase
|
||||
- Do NOT use Conventional Commits style (`feat(scope): …`)
|
||||
|
||||
Examples: `[FEAT][GEMV] add batched forward pass` · `[BUG][FLASH_ATTN] fix bf16 overflow in softmax` · `[PERF][GEMM] optimize shared memory layout for H100`
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Run tests and benchmarks specified in a context.json file
|
||||
# Usage: run-affected-tests.sh <context.json>
|
||||
#
|
||||
# Reads test_targets and bench_targets from the JSON file,
|
||||
# executes each via pytest, and outputs structured JSON results.
|
||||
#
|
||||
# Designed to be called by lifecycle-issue-fixer and lifecycle-pull-request skills.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Argument validation ---
|
||||
|
||||
# Check jq availability first (before any jq usage)
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo '{"status":"error","message":"jq not found. Install jq or ensure it is on PATH."}' >&1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
jq -n '{status: "error", message: "Usage: run-affected-tests.sh <context.json>"}' >&1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONTEXT_FILE="$1"
|
||||
|
||||
if [[ ! -f "$CONTEXT_FILE" ]]; then
|
||||
jq -n --arg file "$CONTEXT_FILE" '{status: "error", message: "Context file not found: \($file)"}' >&1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Parse context.json ---
|
||||
|
||||
# Validate JSON structure
|
||||
if ! jq -e '.' "$CONTEXT_FILE" &>/dev/null; then
|
||||
echo '{"status":"error","message":"Invalid JSON in context file"}' >&1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract test_targets (required, must be JSON array)
|
||||
TEST_TARGETS=$(jq -c '.test_targets // empty' "$CONTEXT_FILE")
|
||||
if [[ -z "$TEST_TARGETS" ]]; then
|
||||
jq -n '{status: "error", message: "context.json missing required field: test_targets"}' >&1
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e 'type == "array"' <<<"$TEST_TARGETS" >/dev/null 2>&1; then
|
||||
jq -n '{status: "error", message: "context.json field test_targets must be a JSON array"}' >&1
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e 'length > 0' <<<"$TEST_TARGETS" >/dev/null 2>&1; then
|
||||
jq -n '{status: "error", message: "context.json field test_targets must contain at least one target"}' >&1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract bench_targets (optional, defaults to empty array, must be JSON array)
|
||||
BENCH_TARGETS=$(jq -c '.bench_targets // []' "$CONTEXT_FILE")
|
||||
if ! jq -e 'type == "array"' <<<"$BENCH_TARGETS" >/dev/null 2>&1; then
|
||||
jq -n '{status: "error", message: "context.json field bench_targets must be a JSON array"}' >&1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Logging (stderr only)
|
||||
log_info() {
|
||||
echo "[run-affected-tests] $1" >&2
|
||||
}
|
||||
|
||||
# --- Run tests ---
|
||||
|
||||
TEST_RESULTS="[]"
|
||||
BENCH_RESULTS="[]"
|
||||
WARNINGS="[]"
|
||||
TOTAL_TESTS=0
|
||||
TOTAL_PASSED=0
|
||||
TOTAL_FAILED=0
|
||||
TOTAL_BENCH=0
|
||||
BENCH_PASSED=0
|
||||
BENCH_FAILED=0
|
||||
|
||||
run_pytest_file() {
|
||||
local file="$1"
|
||||
local category="$2" # "test" or "bench"
|
||||
|
||||
# Reject absolute paths and path traversal attempts
|
||||
if [[ "$file" == /* ]] || [[ "$file" == *../* ]]; then
|
||||
WARNINGS=$(echo "$WARNINGS" | jq --arg w "${file} rejected: absolute or traversal path" '. + [$w]')
|
||||
log_info "WARNING: $file rejected (path security check), skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
WARNINGS=$(echo "$WARNINGS" | jq --arg w "${file} not found, skipped" '. + [$w]')
|
||||
log_info "WARNING: $file not found, skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Running: $file"
|
||||
|
||||
# Run pytest and capture output
|
||||
local pytest_output
|
||||
local exit_code=0
|
||||
pytest_output=$(PYTHONPATH="$PWD" python -m pytest -v --tb=short -q -- "$file" 2>&1) || exit_code=$?
|
||||
|
||||
# Parse pytest summary counts (e.g., "3 passed, 1 failed, 2 errors")
|
||||
local passed failed errors
|
||||
passed=$(echo "$pytest_output" | grep -oE '[0-9]+ passed' | tail -1 | grep -oE '[0-9]+' || echo 0)
|
||||
failed=$(echo "$pytest_output" | grep -oE '[0-9]+ failed' | tail -1 | grep -oE '[0-9]+' || echo 0)
|
||||
errors=$(echo "$pytest_output" | grep -oE '[0-9]+ errors?' | tail -1 | grep -oE '[0-9]+' || echo 0)
|
||||
|
||||
# Extract failure/error names if any
|
||||
local failures="[]"
|
||||
if [[ $failed -gt 0 ]] || [[ $errors -gt 0 ]]; then
|
||||
failures=$(echo "$pytest_output" | { grep -E '^(FAILED |ERROR )' || true; } | sed -E 's/^(FAILED |ERROR )//; s/ - .*$//' | jq -R -s 'split("\n") | map(select(. != ""))')
|
||||
fi
|
||||
|
||||
# Determine status: non-zero exit code OR any failed/error count means failure
|
||||
# This catches collection errors (e.g., ModuleNotFoundError) where pytest exits
|
||||
# non-zero but reports no "failed" count in the summary line.
|
||||
local status="pass"
|
||||
if [[ $exit_code -ne 0 ]] || [[ $failed -gt 0 ]] || [[ $errors -gt 0 ]]; then
|
||||
status="fail"
|
||||
# If pytest exited non-zero but no failed/errors were parsed,
|
||||
# this is a collection/import error — count it as 1 error
|
||||
if [[ $failed -eq 0 ]] && [[ $errors -eq 0 ]]; then
|
||||
errors=1
|
||||
failures=$(echo "$pytest_output" | tail -5 | jq -R -s 'split("\n") | map(select(. != ""))')
|
||||
fi
|
||||
fi
|
||||
|
||||
# Include errors in failed count for aggregation
|
||||
local total_failed=$((failed + errors))
|
||||
|
||||
local result
|
||||
result=$(jq -n \
|
||||
--arg file "$file" \
|
||||
--arg status "$status" \
|
||||
--argjson passed "$passed" \
|
||||
--argjson failed "$total_failed" \
|
||||
--argjson failures "$failures" \
|
||||
'{file: $file, status: $status, passed: $passed, failed: $failed, failures: $failures}')
|
||||
|
||||
if [[ "$category" == "test" ]]; then
|
||||
TEST_RESULTS=$(echo "$TEST_RESULTS" | jq --argjson r "$result" '. + [$r]')
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + passed + total_failed))
|
||||
TOTAL_PASSED=$((TOTAL_PASSED + passed))
|
||||
TOTAL_FAILED=$((TOTAL_FAILED + total_failed))
|
||||
else
|
||||
BENCH_RESULTS=$(echo "$BENCH_RESULTS" | jq --argjson r "$result" '. + [$r]')
|
||||
TOTAL_BENCH=$((TOTAL_BENCH + passed + total_failed))
|
||||
BENCH_PASSED=$((BENCH_PASSED + passed))
|
||||
BENCH_FAILED=$((BENCH_FAILED + total_failed))
|
||||
fi
|
||||
}
|
||||
|
||||
# Run test targets (process substitution avoids subshell — variable updates preserved)
|
||||
log_info "=== Running test targets ==="
|
||||
while IFS= read -r file; do
|
||||
run_pytest_file "$file" "test"
|
||||
done < <(jq -r '.[]' <<<"$TEST_TARGETS")
|
||||
|
||||
# Run bench targets
|
||||
log_info "=== Running benchmark targets ==="
|
||||
while IFS= read -r file; do
|
||||
run_pytest_file "$file" "bench"
|
||||
done < <(jq -r '.[]' <<<"$BENCH_TARGETS")
|
||||
|
||||
# --- Determine overall status ---
|
||||
|
||||
OVERALL_STATUS="pass"
|
||||
if [[ $TOTAL_FAILED -gt 0 ]] || [[ $BENCH_FAILED -gt 0 ]]; then
|
||||
OVERALL_STATUS="fail"
|
||||
fi
|
||||
|
||||
# Check for partial: some passed, some failed
|
||||
if [[ "$OVERALL_STATUS" == "fail" ]] && [[ $TOTAL_PASSED -gt 0 || $BENCH_PASSED -gt 0 ]]; then
|
||||
OVERALL_STATUS="partial"
|
||||
fi
|
||||
|
||||
# --- Output structured JSON ---
|
||||
|
||||
jq -n \
|
||||
--arg status "$OVERALL_STATUS" \
|
||||
--argjson test_total "$TOTAL_TESTS" \
|
||||
--argjson test_passed "$TOTAL_PASSED" \
|
||||
--argjson test_failed "$TOTAL_FAILED" \
|
||||
--argjson test_results "$TEST_RESULTS" \
|
||||
--argjson bench_total "$TOTAL_BENCH" \
|
||||
--argjson bench_passed "$BENCH_PASSED" \
|
||||
--argjson bench_failed "$BENCH_FAILED" \
|
||||
--argjson bench_results "$BENCH_RESULTS" \
|
||||
--argjson warnings "$WARNINGS" \
|
||||
'{
|
||||
status: $status,
|
||||
tests: {
|
||||
total: $test_total,
|
||||
passed: $test_passed,
|
||||
failed: $test_failed,
|
||||
results: $test_results
|
||||
},
|
||||
benchmarks: {
|
||||
total: $bench_total,
|
||||
passed: $bench_passed,
|
||||
failed: $bench_failed,
|
||||
results: $bench_results
|
||||
},
|
||||
warnings: $warnings
|
||||
}'
|
||||
|
||||
# Exit non-zero when tests/benchmarks failed so callers can use exit code as HARD GATE
|
||||
if [[ "$OVERALL_STATUS" != "pass" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Validate PR metadata for TileOPs
|
||||
# Usage: validate.sh <owner/repo> <pr_number>
|
||||
#
|
||||
# Checks: title format, body sections, labels, MCP pitfall
|
||||
# Exit 0 = all checks pass
|
||||
# Exit 1 = at least one check failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source canonical type definitions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
source "$REPO_ROOT/.claude/conventions/types.sh"
|
||||
|
||||
OWNER_REPO="${1:?Usage: validate.sh <owner/repo> <pr_number>}"
|
||||
PR_NUMBER="${2:?Usage: validate.sh <owner/repo> <pr_number>}"
|
||||
|
||||
ERRORS=0
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}✓${NC} $1"; }
|
||||
fail() { echo -e "${RED}✗${NC} $1" >&2; ERRORS=$((ERRORS + 1)); }
|
||||
warn() { echo -e "${YELLOW}⚠${NC} $1" >&2; }
|
||||
|
||||
# --- Fetch PR data ---
|
||||
|
||||
PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$OWNER_REPO" --json title,body,labels 2>&1) || {
|
||||
echo -e "${RED}✗ Failed to fetch PR #${PR_NUMBER}: ${PR_JSON}${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
||||
BODY=$(echo "$PR_JSON" | jq -r '.body')
|
||||
LABEL_COUNT=$(echo "$PR_JSON" | jq '.labels | length')
|
||||
|
||||
echo "=== PR #${PR_NUMBER} validation ==="
|
||||
echo "Title: ${TITLE}"
|
||||
echo ""
|
||||
|
||||
# --- Checks ---
|
||||
|
||||
# 1. PR title format
|
||||
if [[ "$TITLE" =~ $COMMIT_MSG_PATTERN ]]; then
|
||||
pass "PR title follows [Type] Description format"
|
||||
else
|
||||
fail "PR title must match [Type] Description (e.g. [Feat] Add forward op)"
|
||||
fi
|
||||
|
||||
# 2. ## Summary section
|
||||
if echo "$BODY" | grep -q '## Summary'; then
|
||||
pass "PR body contains ## Summary section"
|
||||
else
|
||||
fail "PR body must contain ## Summary section"
|
||||
fi
|
||||
|
||||
# 3. ## Test plan section
|
||||
if echo "$BODY" | grep -q '## Test plan'; then
|
||||
pass "PR body contains ## Test plan section"
|
||||
else
|
||||
fail "PR body must contain ## Test plan section"
|
||||
fi
|
||||
|
||||
# 4. At least one label
|
||||
if [[ "$LABEL_COUNT" -gt 0 ]]; then
|
||||
LABELS=$(echo "$PR_JSON" | jq -r '[.labels[].name] | join(", ")')
|
||||
pass "PR has ${LABEL_COUNT} label(s): ${LABELS}"
|
||||
else
|
||||
fail "PR must have at least one label"
|
||||
fi
|
||||
|
||||
# 5. No literal \n in body (MCP pitfall)
|
||||
if echo "$BODY" | grep -q '\\n'; then
|
||||
fail "PR body contains literal \\\\n — use actual newlines instead"
|
||||
else
|
||||
pass "PR body uses actual newlines (no MCP pitfall)"
|
||||
fi
|
||||
|
||||
# 6. BugFix should have Regression section (warning only)
|
||||
if [[ "$TITLE" =~ ^\[BugFix\] ]] && ! echo "$BODY" | grep -q '## Regression'; then
|
||||
warn "[BugFix] PR should include ## Regression section"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [[ $ERRORS -gt 0 ]]; then
|
||||
echo -e "${RED}FAILED: ${ERRORS} check(s) failed${NC}" >&2
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}ALL CHECKS PASSED${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Wait for GPU 0 to become idle before running benchmarks.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — GPU 0 is idle (no compute processes)
|
||||
# 1 — nvidia-smi not found (no GPU)
|
||||
# 2 — GPU 0 still busy after all retries
|
||||
#
|
||||
# Usage: wait-for-gpu.sh [max_retries] [interval_seconds]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAX_RETRIES="${1:-5}"
|
||||
INTERVAL="${2:-30}"
|
||||
|
||||
if ! command -v nvidia-smi &>/dev/null; then
|
||||
echo "nvidia-smi not found — no GPU available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for ((i = 1; i <= MAX_RETRIES; i++)); do
|
||||
PROCS=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader -i 0 2>/dev/null || true)
|
||||
if [[ -z "$PROCS" ]]; then
|
||||
echo "GPU 0 is idle"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$i" -lt "$MAX_RETRIES" ]]; then
|
||||
echo "GPU 0 busy (attempt $i/$MAX_RETRIES), waiting ${INTERVAL}s..."
|
||||
sleep "$INTERVAL"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "GPU 0 still busy after $MAX_RETRIES attempts"
|
||||
exit 2
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
name: New Operator Sub-task
|
||||
about: A specific sub-task for implementing a part of a new operator
|
||||
title: '[<Feat/Perf/Fix>][Ops] <Operator Name>: <manifest/test/impl/bench scope>'
|
||||
labels: sub-task, operator
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Parent Issue
|
||||
|
||||
<!-- Link to the main tracking issue for this operator using #IssueID -->
|
||||
|
||||
Part of #
|
||||
|
||||
## Task Type
|
||||
|
||||
<!-- Please check the relevant component for this sub-issue -->
|
||||
|
||||
- [ ] **Manifest / Spec** (required for any public Op)
|
||||
- [ ] **Kernel / Op Implementation**
|
||||
- [ ] **Correctness Tests / Workloads**
|
||||
- [ ] **Benchmark / Performance**
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Detailed description of what needs to be implemented in this step -->
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--
|
||||
Refer to:
|
||||
- docs/design/manifest.md for manifest fields and validation levels
|
||||
- docs/design/ops-design.md for Op/Kernel implementation rules
|
||||
- docs/design/testing.md for tests, workloads, and benchmark structure
|
||||
- docs/design/roofline.md for roofline authoring and benchmark consumers
|
||||
-->
|
||||
|
||||
- [ ] Public Op has a `tileops/manifest/` entry (or updates an existing entry).
|
||||
- [ ] Manifest `signature` declares inputs, outputs, params, shape rules, and dtype coverage.
|
||||
- [ ] Manifest `workloads` declare benchmark shapes/dtypes; unit-test edge cases are not generated from manifest workloads.
|
||||
- [ ] Manifest `roofline` is present and consumable by `op.eval_roofline()`.
|
||||
- [ ] Manifest `source` declares kernel, op, test, bench, and `source.kernel_map` when dispatching kernels.
|
||||
- [ ] Op constructor, `forward()`, and `default_kernel_map` match the manifest entry.
|
||||
- [ ] Tests use an independent reference implementation and cover relevant FP16/BF16 and edge cases.
|
||||
- [ ] Benchmarks consume manifest workloads and record at least one non-`tileops` baseline unless explicitly justified.
|
||||
- [ ] PR title and commits follow the current `[Feat][Scope]` / `[Perf][Scope]` / `[Fix][Scope]` convention.
|
||||
- [ ] Implementation follows **Google Python Style** for code and docstrings.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<!-- PR title: [operator-name] feat: add spec-only Manifest -->
|
||||
|
||||
## Group and Operator
|
||||
|
||||
- Group number:
|
||||
- Members:
|
||||
- Operator name:
|
||||
- Operator claim Issue (create it by following [Issue #1](https://gitlink.org.cn/ccf-ai-infra/TileOPs-Metax/issues/1)):
|
||||
|
||||
## Source and Specification
|
||||
|
||||
- Source file path:
|
||||
- Source commit SHA:
|
||||
- Manifest file:
|
||||
- Inputs, outputs, and supported dtype/shape:
|
||||
- Representative workloads:
|
||||
- Roofline formulas:
|
||||
|
||||
## Pre-submission Checklist
|
||||
|
||||
- [ ] Manifest status is `spec-only`
|
||||
- [ ] `git diff --check` passes
|
||||
- [ ] `python scripts/validate_manifest.py` passes
|
||||
- [ ] This PR contains no Op, Kernel, Benchmark, or unrelated changes
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<!-- PR 标题:[算子名] feat: 新增 spec-only Manifest -->
|
||||
|
||||
## 小组与算子
|
||||
|
||||
- 小组编号:
|
||||
- 小组成员:
|
||||
- 算子名称:
|
||||
- 本组算子认领 Issue 链接(请参照 [Issue #1](https://gitlink.org.cn/ccf-ai-infra/TileOPs-Metax/issues/1) 创建):
|
||||
|
||||
## 来源与规范
|
||||
|
||||
- 源文件路径:
|
||||
- 源提交 SHA:
|
||||
- Manifest 文件:
|
||||
- 输入、输出及支持的 dtype/shape:
|
||||
- 典型工作负载:
|
||||
- Roofline 公式:
|
||||
|
||||
## 提交前确认
|
||||
|
||||
- [ ] Manifest 状态为 `spec-only`
|
||||
- [ ] `git diff --check` 通过
|
||||
- [ ] `python scripts/validate_manifest.py` 通过
|
||||
- [ ] 本 PR 不包含 Op、Kernel、Benchmark 或无关修改
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<!--
|
||||
Language: English
|
||||
Chinese template: .github/PULL_REQUEST_TEMPLATE/operator-migration.zh-CN.md
|
||||
|
||||
PR title format:
|
||||
[operator-name] optimize: brief description of this optimization
|
||||
[operator-name] feat: brief description of this new feature
|
||||
|
||||
Choose either optimize or feat; do not keep both.
|
||||
-->
|
||||
|
||||
## Group Project Information
|
||||
|
||||
Project title:
|
||||
|
||||
Full project overview: Summarize the group's overall research goal, target
|
||||
operator, main optimization direction, and expected deliverables.
|
||||
|
||||
### PR Summary
|
||||
|
||||
Operator name:
|
||||
|
||||
Operator claim Issue:
|
||||
|
||||
Group: Group XX
|
||||
|
||||
Members: XXX, XXX, XXX
|
||||
|
||||
Change type:
|
||||
|
||||
- [ ] `feat`: add a new operator or capability
|
||||
- [ ] `optimize`: optimize an existing implementation
|
||||
|
||||
### 1. Optimization Approach in This PR
|
||||
|
||||
Briefly describe the hardware optimization techniques used, such as memory access
|
||||
optimization, pipelining, tiling, or compute-unit communication optimization.
|
||||
|
||||
Primary issue before optimization:
|
||||
|
||||
Optimization approach:
|
||||
|
||||
Key code or configuration changes:
|
||||
|
||||
### 2. Correctness Validation
|
||||
|
||||
Reference implementation:
|
||||
|
||||
Test command:
|
||||
|
||||
Test shape/dtype:
|
||||
|
||||
Error tolerance (atol/rtol):
|
||||
|
||||
Validation result:
|
||||
|
||||
### 3. Performance Data [Required]
|
||||
|
||||
Test environment (GPU, driver, MACA, PyTorch, TileLang):
|
||||
|
||||
Reproduction command:
|
||||
|
||||
Before optimization:
|
||||
|
||||
After optimization:
|
||||
|
||||
Speedup:
|
||||
|
||||
Bottleneck analysis (mcProfiler observations):
|
||||
|
||||
### 4. Submission Checklist
|
||||
|
||||
- [ ] All test cases pass and correctness meets the required tolerance
|
||||
- [ ] Temporary debug output and redundant test code have been removed
|
||||
- [ ] Performance results are locally reproducible and based on valid measurements
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<!--
|
||||
语言:简体中文
|
||||
英文模板:.github/PULL_REQUEST_TEMPLATE/operator-migration.en.md【建议选择中文】
|
||||
|
||||
PR 标题规范:
|
||||
[算子名] optimize: 本次优化简短说明
|
||||
[算子名] feat: 本次新增功能简短说明
|
||||
|
||||
请在 optimize/feat 中选择一个,不要同时保留。
|
||||
-->
|
||||
|
||||
## 小组课题信息
|
||||
|
||||
课题名称:
|
||||
|
||||
课题完整简介:概括本组整体研究目标、目标算子、核心优化方向、预期产出成果。
|
||||
|
||||
### PR 简要描述
|
||||
|
||||
算子名称:
|
||||
|
||||
算子认领 Issue:
|
||||
|
||||
小组:XX 组
|
||||
|
||||
成员:XXX、XXX、XXX
|
||||
|
||||
改动类型:
|
||||
|
||||
- [ ] `feat`:新增算子或功能
|
||||
- [ ] `optimize`:优化已有实现
|
||||
|
||||
### 1. 本次 PR 优化方案
|
||||
|
||||
简述采用的硬件优化手段,例如访存优化、流水线优化、分块策略或计算单元通信优化。
|
||||
|
||||
优化前的主要问题:
|
||||
|
||||
采用的优化方案:
|
||||
|
||||
关键代码或配置变更:
|
||||
|
||||
### 2. 精度验证
|
||||
|
||||
对比基准实现:
|
||||
|
||||
测试命令:
|
||||
|
||||
测试 shape/dtype:
|
||||
|
||||
误差范围(atol/rtol):
|
||||
|
||||
验证结果:
|
||||
|
||||
### 3. 性能数据【必填】
|
||||
|
||||
测试环境(GPU、驱动、MACA、PyTorch、TileLang):
|
||||
|
||||
性能复现命令:
|
||||
|
||||
优化前:
|
||||
|
||||
优化后:
|
||||
|
||||
加速比:
|
||||
|
||||
瓶颈分析(mcProfiler 观测结果):
|
||||
|
||||
### 4. 提交自检清单
|
||||
|
||||
- [ ] 全部测试用例运行通过,精度达标
|
||||
- [ ] 已删除临时调试打印、冗余测试代码
|
||||
- [ ] 性能指标可本地复现,数据真实有效
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
self-hosted-runner:
|
||||
labels:
|
||||
- tile-ops
|
||||
- venv
|
||||
- tileops-metax-runner
|
||||
- nightly
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
name: Reclaim runner disk
|
||||
description: >
|
||||
Trim the persistent bind-mounted /ci-cache (compiled-kernel / triton / pip
|
||||
caches and the tilelang autotuner cache) on self-hosted CI runners. Fails
|
||||
fast if the cache volume is still near full after reclaim, so downstream
|
||||
steps report disk pressure clearly instead of a cryptic mkdir error.
|
||||
|
||||
inputs:
|
||||
cache-age-days:
|
||||
description: Age threshold (days) for trimming cache files.
|
||||
required: false
|
||||
default: "7"
|
||||
min-avail-gib:
|
||||
description: >
|
||||
Hard floor on free space (GiB) in RUNNER_TEMP after reclaim. Below this
|
||||
the step fails with an explicit message.
|
||||
required: false
|
||||
default: "5"
|
||||
reclaim-below-gib:
|
||||
description: >
|
||||
Only attempt reclaim work when RUNNER_TEMP has less than this many GiB
|
||||
free, unless force-reclaim is enabled.
|
||||
required: false
|
||||
default: "30"
|
||||
cache-trim-cooldown-minutes:
|
||||
description: >
|
||||
Minimum interval between the expensive recursive cache-trim passes on the
|
||||
same runner. Cheap disk checks still run on every invocation.
|
||||
required: false
|
||||
default: "0"
|
||||
cache-dirs:
|
||||
description: >
|
||||
Newline-separated list of cache directory roots whose files older than
|
||||
cache-age-days should be trimmed at file granularity. Defaults target
|
||||
the bind-mounted /ci-cache runner cache layout.
|
||||
Do NOT include atomic cache roots here (see atomic-cache-dirs); file-
|
||||
level trim on an atomic root can produce a half-dead "directory exists
|
||||
but sentinel missing" state that crashes downstream consumers.
|
||||
required: false
|
||||
default: |
|
||||
/ci-cache/triton
|
||||
/ci-cache/pip
|
||||
/ci-cache/tilelang/tmp
|
||||
atomic-cache-dirs:
|
||||
description: >
|
||||
Newline-separated list of cache directory roots whose first-level
|
||||
subdirectories are *atomic units*: consumers assume "directory exists
|
||||
=> contents complete" and the subdir is only valid when the sentinel
|
||||
file (best_config.json, hardcoded) is present. These roots get two
|
||||
passes instead of file-level trim:
|
||||
1. Unconditional sentinel-repair: remove any first-level subdir
|
||||
missing the sentinel (self-heals half-dead state from prior runs).
|
||||
2. Age-based atomic-trim: if the newest file in a first-level subdir
|
||||
is older than cache-age-days, delete the whole subdir — never
|
||||
individual files inside it.
|
||||
Default targets the tilelang autotuner cache, which stores per-shape
|
||||
tuning artefacts alongside a best_config.json sentinel.
|
||||
required: false
|
||||
default: |
|
||||
/ci-cache/tilelang/autotuner
|
||||
df-paths:
|
||||
description: Space-separated paths to pass to `df -h` for before/after logging.
|
||||
required: false
|
||||
default: ""
|
||||
force-reclaim:
|
||||
description: >
|
||||
When "true", bypass reclaim-below-gib and cache-trim cooldown checks and
|
||||
always run the full reclaim pass.
|
||||
required: false
|
||||
default: "false"
|
||||
skip-atomic-age-trim:
|
||||
description: >
|
||||
When "true", skip the age-based atomic-trim pass on atomic cache roots.
|
||||
Sentinel-repair on those roots still runs unconditionally (it self-heals
|
||||
half-dead state and is cheap). Callers that run on every PR should opt
|
||||
out of the destructive whole-subdir trim so it only runs via the daily
|
||||
runner-maintenance.yml job; co-locating it on the per-PR path evicts
|
||||
autotuner entries the next PR would otherwise reuse.
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Reclaim runner disk
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_AGE_DAYS: ${{ inputs.cache-age-days }}
|
||||
MIN_AVAIL_GIB: ${{ inputs.min-avail-gib }}
|
||||
RECLAIM_BELOW_GIB: ${{ inputs.reclaim-below-gib }}
|
||||
CACHE_TRIM_COOLDOWN_MINUTES: ${{ inputs.cache-trim-cooldown-minutes }}
|
||||
CACHE_DIRS: ${{ inputs.cache-dirs }}
|
||||
ATOMIC_CACHE_DIRS: ${{ inputs.atomic-cache-dirs }}
|
||||
DF_PATHS: ${{ inputs.df-paths }}
|
||||
FORCE_RECLAIM: ${{ inputs.force-reclaim }}
|
||||
SKIP_ATOMIC_AGE_TRIM: ${{ inputs.skip-atomic-age-trim }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# The reclaim primitives (sentinel-repair, atomic-trim, trim-files)
|
||||
# live in reclaim_cache.sh (colocated with this action.yml) so
|
||||
# they can be exercised under pytest without a self-hosted
|
||||
# runner, *and* so the gpu-smoke trusted-action sparse-checkout
|
||||
# (which only pulls `.github/actions`) still picks them up.
|
||||
# This composite action is a thin policy wrapper — gating (when
|
||||
# to run), not mechanism (how to trim).
|
||||
#
|
||||
# ${GITHUB_ACTION_PATH} resolves to
|
||||
# .../.github/actions/reclaim-runner-disk
|
||||
# and the script sits alongside action.yml so the trusted-action
|
||||
# sparse-checkout (which only pulls .github/actions) still picks
|
||||
# it up on the gpu-smoke job.
|
||||
RECLAIM_SCRIPT="${GITHUB_ACTION_PATH}/reclaim_cache.sh"
|
||||
if [[ ! -f "${RECLAIM_SCRIPT}" || ! -r "${RECLAIM_SCRIPT}" ]]; then
|
||||
echo "::error::reclaim-runner-disk: expected ${RECLAIM_SCRIPT} to exist and be readable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse a newline-separated input (as received from YAML `|` block
|
||||
# scalars) into a bash array, stripping CR and skipping blank
|
||||
# lines. Usage:
|
||||
# parse_list_into_array TARGET_ARRAY <<< "${INPUT_VAR}"
|
||||
# The named array is declared via `declare -n`; callers must have
|
||||
# declared the target array with `local -a`/`declare -a` first, or
|
||||
# let this helper create it implicitly (we do the latter — the
|
||||
# helper resets the target to a fresh empty array on each call).
|
||||
parse_list_into_array() {
|
||||
local -n _target_array="$1"
|
||||
_target_array=()
|
||||
local _line
|
||||
while IFS= read -r _line; do
|
||||
_line="${_line//$'\r'/}"
|
||||
[[ -z "$_line" ]] && continue
|
||||
_target_array+=( "$_line" )
|
||||
done
|
||||
}
|
||||
|
||||
# Validate numeric inputs up-front so typos fail with a clear error
|
||||
# instead of a cryptic bash arithmetic/find error deeper in the script.
|
||||
for var in CACHE_AGE_DAYS MIN_AVAIL_GIB RECLAIM_BELOW_GIB CACHE_TRIM_COOLDOWN_MINUTES; do
|
||||
val="${!var}"
|
||||
if [[ ! "${val}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::reclaim-runner-disk: input ${var}='${val}' is not a non-negative integer"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${DF_PATHS}" ]]; then
|
||||
read -r -a DF_TARGETS <<< "${DF_PATHS}"
|
||||
else
|
||||
DF_TARGETS=()
|
||||
for path in "${RUNNER_TEMP:-}" /ci-cache; do
|
||||
[[ -n "${path}" ]] && DF_TARGETS+=( "${path}" )
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Disk before reclaim:"
|
||||
df -h "${DF_TARGETS[@]}" 2>/dev/null || true
|
||||
|
||||
if [[ -z "${RUNNER_TEMP:-}" ]]; then
|
||||
echo "::error::reclaim-runner-disk: RUNNER_TEMP is unset; cannot enforce disk floor"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AVAIL_KB=$(df -Pk "${RUNNER_TEMP}" | awk 'NR==2 {print $4}')
|
||||
MIN_KB=$(( MIN_AVAIL_GIB * 1024 * 1024 ))
|
||||
RECLAIM_BELOW_KB=$(( RECLAIM_BELOW_GIB * 1024 * 1024 ))
|
||||
# Stamp lives on the persistent bind-mounted cache (not the ephemeral container fs under
|
||||
# /home/ci-runner), so the cache-trim cooldown survives across ephemeral runner jobs.
|
||||
CACHE_TRIM_STAMP="/ci-cache/.ci-maintenance/reclaim-runner-disk-cache-trim.stamp"
|
||||
DID_RECLAIM="false"
|
||||
SHOULD_RECLAIM="false"
|
||||
|
||||
# 0) Unconditional sentinel-repair on atomic cache roots.
|
||||
# Runs regardless of SHOULD_RECLAIM so that half-dead state
|
||||
# left by an earlier file-level trim (directory exists but
|
||||
# best_config.json missing) is healed on the *next* invocation,
|
||||
# not only when disk pressure crosses the reclaim threshold.
|
||||
# The per-subdir cost is a single readdir + stat, so this is
|
||||
# safe to run every time.
|
||||
if [[ -n "${ATOMIC_CACHE_DIRS}" ]]; then
|
||||
parse_list_into_array ATOMIC_ARGS <<< "${ATOMIC_CACHE_DIRS}"
|
||||
if (( ${#ATOMIC_ARGS[@]} > 0 )); then
|
||||
bash "${RECLAIM_SCRIPT}" sentinel-repair "${ATOMIC_ARGS[@]}" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${FORCE_RECLAIM}" == "true" ]]; then
|
||||
SHOULD_RECLAIM="true"
|
||||
echo "force-reclaim enabled: running full reclaim pass"
|
||||
elif [[ -n "${AVAIL_KB:-}" && "${AVAIL_KB}" -lt "${RECLAIM_BELOW_KB}" ]]; then
|
||||
SHOULD_RECLAIM="true"
|
||||
echo "Free space below ${RECLAIM_BELOW_GIB}GiB: running reclaim pass"
|
||||
else
|
||||
echo "Free space is healthy; skipping reclaim pass"
|
||||
fi
|
||||
|
||||
# 1) Age-based trim of the file-granularity cache roots, plus
|
||||
# atomic-granularity trim of atomic cache roots.
|
||||
SHOULD_TRIM_CACHE="false"
|
||||
if [[ "${SHOULD_RECLAIM}" == "true" && ( -n "${CACHE_DIRS}" || -n "${ATOMIC_CACHE_DIRS}" ) ]]; then
|
||||
if [[ "${FORCE_RECLAIM}" == "true" || "${CACHE_TRIM_COOLDOWN_MINUTES}" == "0" ]]; then
|
||||
SHOULD_TRIM_CACHE="true"
|
||||
elif [[ ! -e "${CACHE_TRIM_STAMP}" ]]; then
|
||||
SHOULD_TRIM_CACHE="true"
|
||||
else
|
||||
now_epoch=$(date +%s)
|
||||
stamp_epoch=$(stat -c %Y "${CACHE_TRIM_STAMP}" 2>/dev/null || echo 0)
|
||||
cooldown_seconds=$(( CACHE_TRIM_COOLDOWN_MINUTES * 60 ))
|
||||
if (( now_epoch - stamp_epoch >= cooldown_seconds )); then
|
||||
SHOULD_TRIM_CACHE="true"
|
||||
else
|
||||
remaining_minutes=$(( (cooldown_seconds - (now_epoch - stamp_epoch) + 59) / 60 ))
|
||||
echo "Skipping cache trim: ${remaining_minutes} minute(s) left in cooldown"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${SHOULD_TRIM_CACHE}" == "true" ]]; then
|
||||
DID_RECLAIM="true"
|
||||
|
||||
# File-level trim for non-atomic roots (triton cache, pip, wheel).
|
||||
if [[ -n "${CACHE_DIRS}" ]]; then
|
||||
parse_list_into_array FILE_TRIM_ARGS <<< "${CACHE_DIRS}"
|
||||
if (( ${#FILE_TRIM_ARGS[@]} > 0 )); then
|
||||
bash "${RECLAIM_SCRIPT}" trim-files "${CACHE_AGE_DAYS}" "${FILE_TRIM_ARGS[@]}" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Whole-subdir trim for atomic roots (tilelang autotuner).
|
||||
# Atomic roots are NEVER file-trimmed: doing so can strand a
|
||||
# subdir without its best_config.json sentinel and crash the
|
||||
# next consumer.
|
||||
#
|
||||
# Callers that run on every PR (gpu-smoke.yml) opt out via
|
||||
# skip-atomic-age-trim so the destructive whole-subdir eviction
|
||||
# only happens via the daily runner-maintenance.yml job;
|
||||
# sentinel-repair above still ran unconditionally, so half-dead
|
||||
# state is healed regardless of this opt-out.
|
||||
if [[ "${SKIP_ATOMIC_AGE_TRIM}" == "true" ]]; then
|
||||
echo "Skipping atomic age-trim (opted out)"
|
||||
elif [[ -n "${ATOMIC_CACHE_DIRS}" ]]; then
|
||||
parse_list_into_array ATOMIC_TRIM_ARGS <<< "${ATOMIC_CACHE_DIRS}"
|
||||
if (( ${#ATOMIC_TRIM_ARGS[@]} > 0 )); then
|
||||
bash "${RECLAIM_SCRIPT}" atomic-trim "${CACHE_AGE_DAYS}" "${ATOMIC_TRIM_ARGS[@]}" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${CACHE_TRIM_STAMP}")"
|
||||
touch "${CACHE_TRIM_STAMP}"
|
||||
fi
|
||||
|
||||
if [[ "${SHOULD_RECLAIM}" != "true" ]]; then
|
||||
echo "Reclaim work skipped because free space is above the proactive threshold"
|
||||
elif [[ "${DID_RECLAIM}" != "true" ]]; then
|
||||
echo "Reclaim pass ran, but nothing was eligible for deletion"
|
||||
fi
|
||||
|
||||
echo "Disk after reclaim:"
|
||||
df -h "${DF_TARGETS[@]}" 2>/dev/null || true
|
||||
|
||||
# Hard guard: abort early with a clear message if still near-full.
|
||||
AVAIL_KB=$(df -Pk "${RUNNER_TEMP}" | awk 'NR==2 {print $4}')
|
||||
if [[ -n "${AVAIL_KB:-}" && "${AVAIL_KB}" -lt "${MIN_KB}" ]]; then
|
||||
echo "::error::Runner ${RUNNER_NAME:-unknown} has <${MIN_AVAIL_GIB}GiB free on ${RUNNER_TEMP} after reclaim (${AVAIL_KB} KiB). Manual cleanup required."
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# reclaim_cache.sh — cache-reclaim primitives used by the
|
||||
# .github/actions/reclaim-runner-disk composite action.
|
||||
#
|
||||
# The action.yml composite used to embed all reclaim logic inline, which
|
||||
# meant the cache-trim paths could only be exercised on a self-hosted
|
||||
# runner. That made it impossible to catch regressions at PR time; a bad
|
||||
# trim rule only surfaced after merge when it had already corrupted the
|
||||
# live cache. This script exposes the trim primitives as
|
||||
# pytest-driveable subcommands so tests/test_reclaim_action.py can
|
||||
# validate behaviour against a tmp_path fixture.
|
||||
#
|
||||
# The core safety invariant this script enforces is that some cache
|
||||
# roots (notably /ci-cache/tilelang/autotuner) store
|
||||
# *atomic* first-level subdirectories: consumers assume "directory
|
||||
# exists => contents complete" and the directory is only valid if the
|
||||
# sentinel file (best_config.json) is present. File-level `-mtime`
|
||||
# trimming on these roots can delete the sentinel while leaving the
|
||||
# rest of the directory in place, producing a half-dead state that
|
||||
# crashes the next consumer. The atomic-trim and sentinel-repair
|
||||
# subcommands here operate at whole-subdir granularity and never touch
|
||||
# individual files inside an atomic root.
|
||||
#
|
||||
# Subcommands:
|
||||
#
|
||||
# sentinel-repair <root> [<root>...]
|
||||
# For each atomic root, delete any first-level subdirectory that
|
||||
# does not contain ${SENTINEL_FILENAME:-best_config.json}. This
|
||||
# self-heals half-dead state left by older reclaim passes.
|
||||
#
|
||||
# atomic-trim <age-days> <root> [<root>...]
|
||||
# For each atomic root, compute the newest file mtime anywhere
|
||||
# inside each first-level subdirectory. If that mtime is older
|
||||
# than <age-days> days, delete the whole subdirectory. Never
|
||||
# trims individual files inside atomic roots.
|
||||
#
|
||||
# trim-files <age-days> <root> [<root>...]
|
||||
# File-level `-mtime +N -delete` trim for non-atomic cache
|
||||
# roots, followed by empty-directory cleanup. This is the legacy
|
||||
# behaviour used for /ci-cache/triton, /ci-cache/pip, etc.
|
||||
#
|
||||
# All subcommands are idempotent, tolerate missing roots (skipped
|
||||
# silently), and never exit non-zero for per-directory errors so a
|
||||
# transient `rm` failure cannot abort the whole reclaim step.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SENTINEL_FILENAME="${SENTINEL_FILENAME:-best_config.json}"
|
||||
|
||||
_log() {
|
||||
echo "$@"
|
||||
}
|
||||
|
||||
# sentinel_repair <root> [<root>...]
|
||||
#
|
||||
# Delete any first-level subdirectory of each root that is missing the
|
||||
# sentinel file. Roots that do not exist are skipped. The sentinel
|
||||
# filename is read from ${SENTINEL_FILENAME:-best_config.json}.
|
||||
sentinel_repair() {
|
||||
local root subdir
|
||||
for root in "$@"; do
|
||||
[[ -z "$root" ]] && continue
|
||||
[[ -d "$root" ]] || continue
|
||||
while IFS= read -r -d '' subdir; do
|
||||
if [[ ! -e "${subdir}/${SENTINEL_FILENAME}" ]]; then
|
||||
_log "sentinel-repair: removing ${subdir} (missing ${SENTINEL_FILENAME})"
|
||||
rm -rf "$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done < <(find "$root" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null)
|
||||
done
|
||||
}
|
||||
|
||||
# atomic_trim <age-days> <root> [<root>...]
|
||||
#
|
||||
# For each first-level subdirectory of each root, compute the newest
|
||||
# file mtime in the whole subtree. If that mtime is older than
|
||||
# <age-days> days, delete the whole subdirectory. Never deletes
|
||||
# individual files; the unit of trim is the subdirectory.
|
||||
atomic_trim() {
|
||||
local age_days="$1"
|
||||
shift
|
||||
local now_epoch cutoff root subdir newest_mtime
|
||||
# Validate age_days is a plain non-negative integer before feeding it
|
||||
# into arithmetic. Bash defaults to base-8 for literals with a leading
|
||||
# zero, so a caller-supplied value like "08"/"09" (e.g. from a
|
||||
# zero-padded workflow input) would abort the whole trim with
|
||||
# "value too great for base". We fail *open* — log and return 0 —
|
||||
# so a bad input can't crash the wider reclaim pass, and we force
|
||||
# base-10 parsing via `10#...` for defence in depth.
|
||||
if [[ ! "$age_days" =~ ^[0-9]+$ ]]; then
|
||||
_log "atomic-trim: ignoring invalid age_days='${age_days}' (expected non-negative integer)"
|
||||
return 0
|
||||
fi
|
||||
now_epoch=$(date +%s)
|
||||
cutoff=$(( now_epoch - 10#${age_days} * 86400 ))
|
||||
for root in "$@"; do
|
||||
[[ -z "$root" ]] && continue
|
||||
[[ -d "$root" ]] || continue
|
||||
while IFS= read -r -d '' subdir; do
|
||||
# Use the newest FILE mtime anywhere in the subtree — directory
|
||||
# mtimes must not participate. Rationale: a cache restore/extract
|
||||
# can bump the subdir's own mtime to "now" while every regular
|
||||
# file inside is still at its original (old) timestamp; counting
|
||||
# the dir mtime would wrongly mark the subdir as fresh and defeat
|
||||
# age-based reclaim. Only fall back to the subdir mtime when the
|
||||
# subtree has no regular files at all.
|
||||
newest_mtime=$(find "$subdir" -type f -printf '%T@\n' 2>/dev/null \
|
||||
| awk 'NR == 1 || $1 > max { max = $1 } END { if (NR > 0) printf "%d\n", max }')
|
||||
if [[ -z "$newest_mtime" ]]; then
|
||||
newest_mtime=$(stat -c %Y "$subdir" 2>/dev/null || echo 0)
|
||||
fi
|
||||
if (( newest_mtime < cutoff )); then
|
||||
_log "atomic-trim: removing ${subdir} (newest mtime ${newest_mtime} < cutoff ${cutoff})"
|
||||
rm -rf "$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done < <(find "$root" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null)
|
||||
done
|
||||
}
|
||||
|
||||
# trim_files <age-days> <root> [<root>...]
|
||||
#
|
||||
# File-level age-based trim for non-atomic caches. Deletes files older
|
||||
# than <age-days>, then deletes any now-empty directories. Empty-dir
|
||||
# cleanup does NOT gate on -mtime because file deletion bumps the
|
||||
# parent directory's mtime to "now" and a mtime filter would leave the
|
||||
# newly-empty dirs behind.
|
||||
trim_files() {
|
||||
local age_days="$1"
|
||||
shift
|
||||
local root
|
||||
# Same validation as atomic_trim: refuse non-integer age_days and fail
|
||||
# open. `find -mtime` itself is tolerant of leading-zero strings, but
|
||||
# we keep the contract identical across subcommands so a caller that
|
||||
# passes a bogus value gets the same behaviour everywhere.
|
||||
if [[ ! "$age_days" =~ ^[0-9]+$ ]]; then
|
||||
_log "trim-files: ignoring invalid age_days='${age_days}' (expected non-negative integer)"
|
||||
return 0
|
||||
fi
|
||||
age_days=$(( 10#${age_days} ))
|
||||
for root in "$@"; do
|
||||
[[ -z "$root" ]] && continue
|
||||
[[ -d "$root" ]] || continue
|
||||
find "$root" -type f -mtime "+${age_days}" -delete 2>/dev/null || true
|
||||
find "$root" -depth -mindepth 1 -type d -empty -delete 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: reclaim_cache.sh <subcommand> [args...]
|
||||
|
||||
subcommands:
|
||||
sentinel-repair <root> [<root>...]
|
||||
atomic-trim <age-days> <root> [<root>...]
|
||||
trim-files <age-days> <root> [<root>...]
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
main() {
|
||||
[[ $# -ge 1 ]] || _usage
|
||||
local cmd="$1"
|
||||
shift
|
||||
case "$cmd" in
|
||||
sentinel-repair)
|
||||
sentinel_repair "$@"
|
||||
;;
|
||||
atomic-trim)
|
||||
[[ $# -ge 2 ]] || _usage
|
||||
atomic_trim "$@"
|
||||
;;
|
||||
trim-files)
|
||||
[[ $# -ge 2 ]] || _usage
|
||||
trim_files "$@"
|
||||
;;
|
||||
*)
|
||||
_usage
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Allow sourcing the file in tests without executing main.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<!--
|
||||
语言:简体中文
|
||||
英文模板:.github/PULL_REQUEST_TEMPLATE/operator-migration.en.md
|
||||
|
||||
PR 标题规范:
|
||||
[算子名] optimize: 本次优化简短说明
|
||||
[算子名] feat: 本次新增功能简短说明
|
||||
|
||||
请在 optimize/feat 中选择一个,不要同时保留。
|
||||
-->
|
||||
|
||||
## 小组课题信息
|
||||
|
||||
课题名称:
|
||||
|
||||
课题完整简介:概括本组整体研究目标、目标算子、核心优化方向、预期产出成果。
|
||||
|
||||
### PR 简要描述
|
||||
|
||||
算子名称:
|
||||
|
||||
小组:XX 组
|
||||
|
||||
成员:XXX、XXX、XXX
|
||||
|
||||
改动类型:
|
||||
|
||||
- [ ] `feat`:新增算子或功能
|
||||
- [ ] `optimize`:优化已有实现
|
||||
|
||||
### 1. 本次 PR 优化方案
|
||||
|
||||
简述采用的硬件优化手段,例如访存优化、流水线优化、分块策略或计算单元通信优化。
|
||||
|
||||
优化前的主要问题:
|
||||
|
||||
采用的优化方案:
|
||||
|
||||
关键代码或配置变更:
|
||||
|
||||
### 2. 精度验证
|
||||
|
||||
对比基准实现:
|
||||
|
||||
测试命令:
|
||||
|
||||
测试 shape/dtype:
|
||||
|
||||
误差范围(atol/rtol):
|
||||
|
||||
验证结果:
|
||||
|
||||
### 3. 性能数据【必填】
|
||||
|
||||
测试环境(GPU、驱动、MACA、PyTorch、TileLang):
|
||||
|
||||
性能复现命令:
|
||||
|
||||
优化前:
|
||||
|
||||
优化后:
|
||||
|
||||
加速比:
|
||||
|
||||
瓶颈分析(mcProfiler 观测结果):
|
||||
|
||||
### 4. 提交自检清单
|
||||
|
||||
- [ ] 全部测试用例运行通过,精度达标
|
||||
- [ ] 已删除临时调试打印、冗余测试代码
|
||||
- [ ] 性能指标可本地复现,数据真实有效
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
# Multi-stage CI runner image for the self-hosted GPU runner.
|
||||
ARG BASE_IMAGE=nvidia/cuda:12.9.1-devel-ubuntu22.04
|
||||
|
||||
# ── runtime ──
|
||||
# devel (not runtime) base: nvcc is needed both at build time (FA3 / tilelang source builds)
|
||||
# and at runtime (tilelang JIT).
|
||||
FROM ${BASE_IMAGE} AS runtime
|
||||
ENV DEBIAN_FRONTEND=noninteractive PIP_NO_CACHE_DIR=1 PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
# 22.04 ships python3.10; install 3.12 from deadsnakes and make it the default python.
|
||||
# software-properties-common is purged after adding the PPA to keep the image lean.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
software-properties-common ca-certificates curl gnupg \
|
||||
&& add-apt-repository -y ppa:deadsnakes/ppa \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3.12 python3.12-dev python3.12-venv \
|
||||
git build-essential cmake ninja-build unzip xz-utils sudo \
|
||||
&& apt-get purge -y --auto-remove software-properties-common \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& python3.12 -m ensurepip --upgrade \
|
||||
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 \
|
||||
&& update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 \
|
||||
&& python -m pip install --upgrade pip
|
||||
COPY constraints.txt /tmp/constraints.txt
|
||||
# torch / torchvision / torchaudio: cu129 wheels from the pytorch index (the PyPI default is a
|
||||
# different CUDA variant, so pin +cu129). Installing torchvision/torchaudio as cu129 here means
|
||||
# vllm later finds its exact-pinned versions already satisfied and never swaps them off cu129.
|
||||
RUN python -m pip install \
|
||||
--index-url https://download.pytorch.org/whl/cu129 \
|
||||
--extra-index-url https://pypi.org/simple \
|
||||
-c /tmp/constraints.txt \
|
||||
"torch==2.10.0+cu129" "torchvision==0.25.0+cu129" "torchaudio==2.10.0+cu129"
|
||||
# tilelang's build backend (scikit-build-core + patchelf, its [build-system].requires) and its
|
||||
# runtime deps, installed here so the tilelang stage can build with --no-build-isolation. cmake
|
||||
# from pip provides >=3.26.1 (tilelang's [tool.scikit-build] floor); jammy apt cmake is 3.22.
|
||||
RUN python -m pip install -c /tmp/constraints.txt \
|
||||
setuptools wheel ninja scikit-build-core patchelf cmake \
|
||||
triton apache-tvm-ffi cloudpickle ml_dtypes numpy psutil tqdm \
|
||||
typing_extensions Cython z3-solver torch_c_dlpack_ext einops "PyYAML>=6.0"
|
||||
ARG MAX_JOBS=64
|
||||
ARG NVCC_THREADS=4
|
||||
# TORCH_CUDA_ARCH_LIST=9.0: the runner pool is Hopper (H200) only. Drives the FA3 and tilelang
|
||||
# source builds in later stages, and runtime tilelang JIT.
|
||||
ENV MAX_JOBS=${MAX_JOBS} NVCC_THREADS=${NVCC_THREADS} TORCH_CUDA_ARCH_LIST=9.0
|
||||
|
||||
# ── post-fa3 ──
|
||||
# Test tooling + FlashAttention-3 (a bench baseline). tilelang is NOT built here — it is built
|
||||
# last (the `tilelang` stage) so a SHA bump rebuilds only that layer and so it links against
|
||||
# the final post-bench stack.
|
||||
FROM runtime AS post-fa3
|
||||
RUN python -m pip install --no-cache-dir -c /tmp/constraints.txt \
|
||||
"pytest==9.0.2" "pytest-xdist>=3.0" "ruff==0.14.13"
|
||||
# FlashAttention-3 (Hopper) has no PyPI wheel — build it from the repo's hopper/ dir.
|
||||
# Fetch ONLY the csrc/cutlass submodule the build needs: a full --recursive also pulls the AMD
|
||||
# composable_kernel submodule, which the sm_90 build never uses and which is large and
|
||||
# slow/timeout-prone to clone. Best-effort: a bench baseline must not break the image. The
|
||||
# source tree is removed whether or not the build succeeds, so it never lands in the image.
|
||||
RUN git clone --depth 1 --branch v2.8.3 \
|
||||
https://github.com/Dao-AILab/flash-attention.git /tmp/flash-attention \
|
||||
&& cd /tmp/flash-attention \
|
||||
&& git submodule update --init --depth 1 csrc/cutlass \
|
||||
&& cd hopper && python setup.py install; \
|
||||
status=$?; rm -rf /tmp/flash-attention; \
|
||||
[ "$status" -eq 0 ] || echo "WARNING: FlashAttention-3 (hopper) build failed (non-fatal)"
|
||||
|
||||
# ── fa2 ──
|
||||
# FlashAttention-2 in its OWN stage/layer: no prebuilt wheel for this torch/cu129/py, so it
|
||||
# builds from source (~minutes). Isolating it means changes to the bench-install loop
|
||||
# (fullstack) never invalidate this expensive compile. flash-attn does not re-resolve torch.
|
||||
# Best-effort: a bench baseline must not fail the image.
|
||||
FROM post-fa3 AS fa2
|
||||
RUN python -m pip install --no-cache-dir --no-build-isolation \
|
||||
--index-url https://download.pytorch.org/whl/cu129 \
|
||||
--extra-index-url https://pypi.org/simple \
|
||||
-c /tmp/constraints.txt "flash-attn==2.8.3" \
|
||||
|| echo "WARNING: flash-attn (FA2) install failed (non-fatal)"
|
||||
|
||||
# ── fullstack ──
|
||||
# Remaining bench baselines (comparison targets). Best-effort. Freeze the cu129 torch trio
|
||||
# that `runtime` installed into a constraint first so a bench dep's unversioned `torch`
|
||||
# requirement cannot swap torch off the cu129 stack; constraints.txt itself stays bare so it
|
||||
# also fits the CPU preflight. vllm pulls flashinfer 0.6.6 (upgraded below) and has no
|
||||
# apache-tvm-ffi pin, so it does not conflict with tilelang. sgl-kernel is not installed.
|
||||
FROM fa2 AS fullstack
|
||||
RUN python -m pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' > /tmp/torch-cu129.txt \
|
||||
&& for pkg in \
|
||||
"flash-linear-attention==0.4.2" \
|
||||
"vllm==0.19.1"; do \
|
||||
python -m pip install --no-cache-dir --no-build-isolation \
|
||||
--index-url https://download.pytorch.org/whl/cu129 \
|
||||
--extra-index-url https://pypi.org/simple \
|
||||
-c /tmp/constraints.txt -c /tmp/torch-cu129.txt "$pkg" \
|
||||
|| echo "WARNING: bench baseline '$pkg' install failed (non-fatal)"; \
|
||||
done
|
||||
# The Mamba benchmarks compare against the official mamba_ssm Triton kernels. Keep this
|
||||
# best-effort and dependency-free so it cannot re-resolve the CUDA-specific torch/triton stack
|
||||
# or pull the newer mamba_ssm tilelang/apache-tvm-ffi pins into the runner image. mamba_ssm's
|
||||
# package import loads selective_scan_cuda, so the CUDA extension must be built even when the
|
||||
# benchmark uses the Triton modules.
|
||||
RUN MAMBA_FORCE_BUILD=TRUE \
|
||||
python -m pip install --no-cache-dir --no-build-isolation --no-deps \
|
||||
-c /tmp/constraints.txt -c /tmp/torch-cu129.txt \
|
||||
"mamba-ssm==2.3.1" \
|
||||
|| echo "WARNING: mamba-ssm 2.3.1 install failed (non-fatal)"
|
||||
# The grouped-GEMM benchmark can compare against DeepGEMM. Install from the official tag
|
||||
# instead of PyPI's stale 1.0.0 sdist, and fetch only the submodules needed for its headers.
|
||||
ARG DEEPGEMM_GIT_SHA=c9f8b34dcdacc20aa746b786f983492c51072870
|
||||
RUN { mkdir -p /tmp/DeepGEMM \
|
||||
&& cd /tmp/DeepGEMM \
|
||||
&& git init -q \
|
||||
&& git remote add origin https://github.com/deepseek-ai/DeepGEMM.git \
|
||||
&& for attempt in 1 2 3 4 5; do \
|
||||
git -c http.version=HTTP/1.1 fetch --depth 1 origin "${DEEPGEMM_GIT_SHA}" && break; \
|
||||
status="$?"; \
|
||||
echo "WARNING: DeepGEMM fetch failed (attempt ${attempt}/5, status ${status})"; \
|
||||
if [ "${attempt}" = 5 ]; then exit "${status}"; fi; \
|
||||
sleep "$((attempt * 10))"; \
|
||||
done \
|
||||
&& git checkout -q FETCH_HEAD \
|
||||
&& for attempt in 1 2 3 4 5; do \
|
||||
git -c http.version=HTTP/1.1 submodule update --init --depth 1 --jobs 1 \
|
||||
third-party/cutlass third-party/fmt && break; \
|
||||
status="$?"; \
|
||||
echo "WARNING: DeepGEMM submodule fetch failed (attempt ${attempt}/5, status ${status})"; \
|
||||
rm -rf third-party/cutlass third-party/fmt .git/modules/third-party/cutlass .git/modules/third-party/fmt; \
|
||||
if [ "${attempt}" = 5 ]; then exit "${status}"; fi; \
|
||||
sleep "$((attempt * 10))"; \
|
||||
done \
|
||||
&& DG_FORCE_BUILD=1 python -m pip install --no-cache-dir --no-build-isolation --no-deps \
|
||||
-c /tmp/constraints.txt -c /tmp/torch-cu129.txt .; } \
|
||||
|| echo "WARNING: DeepGEMM v2.1.1.post3 install failed (non-fatal)"; \
|
||||
rm -rf /tmp/DeepGEMM
|
||||
# The attention benchmarks (benchmarks/ops/attention/) need a flashinfer newer than the 0.6.6
|
||||
# vllm pulls. Upgrade flashinfer-python/-cubin with --no-deps so torch and vllm's other deps are
|
||||
# untouched; add plain cuda-tile (flashinfer>=0.6.7 needs it — the [tileiras] extra would pull a
|
||||
# CUDA-13 toolchain). vllm's flashinfer pin is left unsatisfied, which is safe: vllm's only
|
||||
# TileOPs-used path (fused_moe) does not import flashinfer.
|
||||
RUN { python -m pip install --no-cache-dir --no-deps -c /tmp/constraints.txt -c /tmp/torch-cu129.txt \
|
||||
"flashinfer-python==0.6.11.post2" "flashinfer-cubin==0.6.11.post2" \
|
||||
&& python -m pip install --no-cache-dir -c /tmp/constraints.txt -c /tmp/torch-cu129.txt \
|
||||
cuda-tile nvidia-cudnn-frontend "nvidia-cutlass-dsl>=4.5.0" nvidia-ml-py; } \
|
||||
|| echo "WARNING: flashinfer 0.6.11.post2 upgrade failed (non-fatal)"
|
||||
|
||||
# ── tilelang ──
|
||||
# Built LAST (after all bench): a TILELANG_GIT_SHA bump then rebuilds only this layer (bench
|
||||
# stays cached), and tilelang compiles/links against the exact final stack so its ABI always
|
||||
# matches what ships. ARG is declared here — not earlier — so changing it never invalidates
|
||||
# the bench layers above.
|
||||
FROM fullstack AS tilelang
|
||||
ARG TILELANG_GIT_SHA
|
||||
ARG TILELANG_VERSION
|
||||
# main mode: --build-arg TILELANG_GIT_SHA=<commit> → clone + compile that commit
|
||||
# release mode: --build-arg TILELANG_VERSION=<version> → pip install the PyPI release
|
||||
# Either way --no-deps, so pip never re-resolves the cu129 stack.
|
||||
RUN if [ -n "${TILELANG_GIT_SHA}" ]; then \
|
||||
mkdir -p /tmp/tilelang && cd /tmp/tilelang \
|
||||
&& git init -q \
|
||||
&& git remote add origin https://github.com/tile-ai/tilelang.git \
|
||||
&& git fetch --depth 1 origin "${TILELANG_GIT_SHA}" \
|
||||
&& git checkout -q FETCH_HEAD \
|
||||
&& git submodule update --init --depth 1 --recursive \
|
||||
3rdparty/composable_kernel 3rdparty/cutlass 3rdparty/tvm \
|
||||
&& mkdir -p /opt/tilelang-wheels \
|
||||
&& CMAKE_BUILD_PARALLEL_LEVEL="${MAX_JOBS}" \
|
||||
python -m pip wheel . --no-deps --no-build-isolation -w /opt/tilelang-wheels \
|
||||
&& python -m pip install --no-deps /opt/tilelang-wheels/tilelang-*.whl \
|
||||
&& rm -rf /tmp/tilelang; \
|
||||
elif [ -n "${TILELANG_VERSION}" ]; then \
|
||||
python -m pip install --no-deps "tilelang==${TILELANG_VERSION}"; \
|
||||
else \
|
||||
echo "ERROR: set --build-arg TILELANG_GIT_SHA=<commit> (main) or TILELANG_VERSION=<ver> (release)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
# Guard the final stack (GPU-free, runs at build): tilelang must import, the installed
|
||||
# apache-tvm-ffi must sit in the tilelang wheel's declared ABI range, and torch must still be
|
||||
# the cu129 build.
|
||||
COPY scripts/ci/verify_runtime_stack.py /tmp/verify_runtime_stack.py
|
||||
RUN python /tmp/verify_runtime_stack.py
|
||||
|
||||
# ── final ──
|
||||
FROM tilelang AS final
|
||||
# Cache dir defaults (host bind-mounts /ci-cache at runtime; pre-created below so they stay
|
||||
# writable when run unmounted). PIP_NO_CACHE_DIR=0 re-enables caching (build stages disable it).
|
||||
ENV AGENT_TOOLSDIRECTORY=/home/ci-runner/runner/_work/_tool \
|
||||
TILELANG_CACHE_DIR=/ci-cache/tilelang \
|
||||
TILELANG_TMP_DIR=/ci-cache/tilelang/tmp \
|
||||
TRITON_CACHE_DIR=/ci-cache/triton \
|
||||
PIP_CACHE_DIR=/ci-cache/pip \
|
||||
PIP_NO_CACHE_DIR=0
|
||||
ARG RUNNER_VERSION=2.334.0
|
||||
# The /ci-cache chown below serves only the unmounted image-verification path
|
||||
# (`docker run <image> python ...`, see README): ci-runner writes the root-created cache dirs
|
||||
# directly. In the mounted CI path the host bind-mount shadows these dirs, so the chown is a
|
||||
# no-op and host ownership wins.
|
||||
# useradd before WORKDIR: home stays ci-runner-owned, and WORKDIR precedes the relative-path RUN (hadolint).
|
||||
# Pin UID/GID 1000 so the account deterministically owns the shared /ci-cache bind-mount across
|
||||
# hosts (it matches the majority of existing warm-cache entries; host normalization preserves them).
|
||||
# FlashInfer's flashinfer-cubin package keeps its packaged cubin tree under site-packages.
|
||||
# Some baselines materialize TRTLLM GEMM entries there at runtime, so the unprivileged runner
|
||||
# must own the writable directories instead of inheriting pip's root-owned directories.
|
||||
RUN groupadd -g 1000 ci-runner && useradd -u 1000 -g 1000 -m -s /bin/bash ci-runner \
|
||||
&& FLASHINFER_CUBIN_DIR="$(python -c 'import pathlib, flashinfer_cubin; print(pathlib.Path(flashinfer_cubin.get_cubin_dir()))' 2>/dev/null || true)" \
|
||||
&& if [ -n "${FLASHINFER_CUBIN_DIR}" ]; then \
|
||||
mkdir -p "${FLASHINFER_CUBIN_DIR}/flashinfer/trtllm/gemm"; \
|
||||
find "${FLASHINFER_CUBIN_DIR}" -type d -exec chown ci-runner:ci-runner {} +; \
|
||||
fi
|
||||
WORKDIR /home/ci-runner/runner
|
||||
RUN mkdir -p /home/ci-runner/runner/_work/_tool \
|
||||
&& curl -fsSL -o runner.tar.gz \
|
||||
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" \
|
||||
&& tar xzf runner.tar.gz && rm runner.tar.gz \
|
||||
&& ./bin/installdependencies.sh \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /ci-cache/pip /ci-cache/tilelang/tmp /ci-cache/triton \
|
||||
&& chown -R ci-runner:ci-runner /home/ci-runner /ci-cache
|
||||
# chmod as root before the USER drop.
|
||||
COPY --chown=ci-runner:ci-runner .github/runner/entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
USER ci-runner
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
# Multi-stage CI runner image for the self-hosted GPU runner.
|
||||
ARG BASE_IMAGE=cr.metax-tech.com/public-library/maca-pytorch:3.8.0.11-torch2.10-py312-ubuntu24.04-amd64
|
||||
|
||||
# ── runtime ──
|
||||
FROM ${BASE_IMAGE} AS runtime
|
||||
ENV DEBIAN_FRONTEND=noninteractive PIP_NO_CACHE_DIR=1 PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git build-essential cmake ccache ninja-build unzip xz-utils sudo \
|
||||
&& python -m pip install --upgrade pip
|
||||
# tilelang's build backend (scikit-build-core + patchelf, its [build-system].requires) and its
|
||||
# runtime deps, installed here so the tilelang stage can build with --no-build-isolation. cmake
|
||||
# from pip provides >=3.26.1 (tilelang's [tool.scikit-build] floor); jammy apt cmake is 3.22.
|
||||
RUN python -m pip install \
|
||||
setuptools wheel ninja scikit-build-core patchelf cmake \
|
||||
triton apache-tvm-ffi cloudpickle ml_dtypes numpy psutil tqdm \
|
||||
typing_extensions Cython z3-solver torch_c_dlpack_ext einops "PyYAML>=6.0"
|
||||
ARG MAX_JOBS=64
|
||||
ENV MAX_JOBS=${MAX_JOBS}
|
||||
|
||||
# ── final ──
|
||||
FROM runtime AS final
|
||||
ENV TILELANG_CACHE_DIR=/ci-cache/tilelang \
|
||||
TILELANG_TMP_DIR=/ci-cache/tilelang/tmp \
|
||||
TRITON_CACHE_DIR=/ci-cache/triton \
|
||||
PIP_CACHE_DIR=/ci-cache/pip \
|
||||
PIP_NO_CACHE_DIR=0
|
||||
ENV USE_MACA=ON \
|
||||
CCACHE_DIR=/ci-cache/ccache \
|
||||
PYTHONPATH=/ci-cache/site-packages \
|
||||
PATH="/opt/conda/bin:${PATH}"
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
# CI runner image
|
||||
|
||||
Multi-stage image for the self-hosted GPU runner. It bakes a tilelang wheel (compiled once
|
||||
from a pinned commit, or installed from a PyPI release) plus the test/benchmark stack onto a
|
||||
public CUDA base, so CI never recompiles tilelang per PR.
|
||||
|
||||
Built **manually on a GPU host** (needs `nvcc`), then pushed to `ghcr.io`. It is **not**
|
||||
built in CI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An NVIDIA GPU host with a CUDA 12.9-capable driver and `nvcc`.
|
||||
- Docker with BuildKit enabled (`DOCKER_BUILDKIT=1`).
|
||||
- Run from the **repository root** — the build context must contain `constraints.txt`,
|
||||
`scripts/ci/verify_runtime_stack.py`, and `.github/runner/entrypoint.sh` (the Dockerfile
|
||||
copies all three).
|
||||
|
||||
## Build
|
||||
|
||||
Provide tilelang one of two ways — pass **exactly one** of these build-args:
|
||||
|
||||
- **main commit**: `--build-arg TILELANG_GIT_SHA=<commit>` — shallow-fetches and compiles that
|
||||
commit. The Dockerfile carries no commit literal; the commit you pass is the single source
|
||||
of truth, and the image tag records it.
|
||||
- **release**: `--build-arg TILELANG_VERSION=<version>` — `pip install tilelang==<version>`.
|
||||
|
||||
```bash
|
||||
# from the repository root (main-commit mode)
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
-f .github/runner/Dockerfile \
|
||||
--target final \
|
||||
--build-arg TILELANG_GIT_SHA=65dbc9837beedf6882a40a08e18ea571d92fd6a5 \
|
||||
-t ghcr.io/tile-ai/tileops-runner:65dbc98 \
|
||||
.
|
||||
```
|
||||
|
||||
Tag with the tilelang commit's **short SHA** (`:65dbc98`). If you rebuild the same commit,
|
||||
add a numeric suffix (`:65dbc98-2`).
|
||||
|
||||
## Roll out an updated runner image
|
||||
|
||||
Changes to this Dockerfile are not picked up by CI automatically. After a Dockerfile change
|
||||
lands, rebuild and tag the image from a GPU host using the build command above, then push it:
|
||||
|
||||
```bash
|
||||
docker push ghcr.io/tile-ai/tileops-runner:<new-tag>
|
||||
```
|
||||
|
||||
Then redeploy the self-hosted runner launcher to use the new tag (maintainer task, done
|
||||
outside this repository). Merging the TileOPs PR only changes the image recipe; the live
|
||||
self-hosted runners keep using their existing image until that manual rollout is done.
|
||||
|
||||
### Build args
|
||||
|
||||
| Arg | Default | Purpose |
|
||||
| ------------------ | ------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| `TILELANG_GIT_SHA` | *(none)* | tilelang commit to shallow-clone and compile (main mode). |
|
||||
| `TILELANG_VERSION` | *(none)* | tilelang PyPI version to `pip install` (release mode). |
|
||||
| `BASE_IMAGE` | `nvidia/cuda:12.9.1-devel-ubuntu22.04` | Public CUDA `devel` base (Python 3.12 via deadsnakes). |
|
||||
| `MAX_JOBS` | `64` | Parallelism for the tilelang / FA2 / FA3 source builds. |
|
||||
| `NVCC_THREADS` | `4` | Per-`nvcc` threads. |
|
||||
| `DEEPGEMM_GIT_SHA` | `c9f8b34dcdacc20aa746b786f983492c51072870` | DeepGEMM commit for the grouped-GEMM benchmark baseline (`v2.1.1.post3`). |
|
||||
| `RUNNER_VERSION` | `2.334.0` | GitHub Actions runner version baked into `final`. |
|
||||
|
||||
Set exactly one of `TILELANG_GIT_SHA` / `TILELANG_VERSION`; the build fails fast if neither is set.
|
||||
|
||||
### Stages (`--target`)
|
||||
|
||||
| Stage | Contents |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `runtime` | Python 3.12 + torch / torchvision / torchaudio `2.10.0 / 0.25.0 / 2.10.0 +cu129` + triton `3.6.0` + tilelang build/runtime deps (incl. `apache-tvm-ffi 0.1.11`). **No tilelang itself.** |
|
||||
| `post-fa3` | `runtime` + pytest / pytest-xdist / ruff + FlashAttention-3 (built from the `hopper/` source). |
|
||||
| `fa2` | `post-fa3` + FlashAttention-2 (`flash-attn 2.8.3`, source-built in its own layer so changes to the bench loop never recompile it). |
|
||||
| `fullstack` | `fa2` + flash-linear-attention `0.4.2` + vLLM `0.19.1` + mamba-ssm `2.3.1` + DeepGEMM `2.1.1.post3`, then flashinfer-python/-cubin upgraded to `0.6.11.post2` (`--no-deps`, so torch stays +cu129). sgl-kernel is not installed. |
|
||||
| `tilelang` | `fullstack` + the tilelang wheel (`--no-deps`), then the build-time guard. Built **last** so a SHA bump rebuilds only this layer. |
|
||||
| `final` | `tilelang` + the GitHub Actions runner (no TileOPs source baked). |
|
||||
|
||||
Build an earlier stage for debugging with `--target runtime` (etc.).
|
||||
|
||||
The `tilelang` stage ends by running `scripts/ci/verify_runtime_stack.py` (GPU-free): it fails
|
||||
the build unless tilelang imports, the installed `apache-tvm-ffi` sits inside the tilelang
|
||||
wheel's declared range, and torch is still the cu129 build.
|
||||
|
||||
## Verify the built image
|
||||
|
||||
```bash
|
||||
docker run --rm --gpus all ghcr.io/tile-ai/tileops-runner:65dbc98 python - <<'PY'
|
||||
import torch
|
||||
print("torch", torch.__version__, "cuda", torch.version.cuda) # expect 2.10.0+cu129, cuda 12.9
|
||||
import tilelang; print("tilelang", tilelang.__version__)
|
||||
import flashinfer, flashinfer_cubin
|
||||
print("flashinfer", flashinfer.__version__)
|
||||
from pathlib import Path
|
||||
cubin_dir = Path(flashinfer_cubin.get_cubin_dir())
|
||||
probe_dir = cubin_dir / "flashinfer" / "trtllm" / "gemm" / "_tileops_write_probe"
|
||||
probe_dir.mkdir(parents=True, exist_ok=True)
|
||||
(probe_dir / "probe.txt").write_text("ok")
|
||||
print("flashinfer cubin write OK")
|
||||
|
||||
import selective_scan_cuda
|
||||
import mamba_ssm
|
||||
from mamba_ssm.ops.triton.ssd_combined import mamba_chunk_scan_combined
|
||||
assert mamba_chunk_scan_combined is not None
|
||||
print("mamba", mamba_ssm.__version__)
|
||||
|
||||
import deep_gemm
|
||||
print("deep_gemm", deep_gemm.__version__)
|
||||
|
||||
# cuBLAS probe: matmul / bmm / einsum on the GPU
|
||||
a = torch.randn(512, 512, device="cuda", dtype=torch.float16)
|
||||
b = torch.randn(512, 512, device="cuda", dtype=torch.float16)
|
||||
assert torch.matmul(a, b).isfinite().all()
|
||||
ab = torch.randn(8, 128, 128, device="cuda", dtype=torch.float16)
|
||||
assert torch.bmm(ab, ab).isfinite().all()
|
||||
assert torch.einsum("bik,bkj->bij", ab, ab).isfinite().all()
|
||||
print("cuBLAS probe OK")
|
||||
PY
|
||||
```
|
||||
|
||||
Then run the smoke tests against a checkout of this repo:
|
||||
|
||||
```bash
|
||||
docker run --rm --gpus all -v "$PWD:/src" -w /src \
|
||||
ghcr.io/tile-ai/tileops-runner:65dbc98 \
|
||||
bash -c 'scripts/ci/install_tileops.sh && pytest -m smoke'
|
||||
```
|
||||
|
||||
`install_tileops.sh` installs TileOPs `--no-deps` against the baked stack; it fails fast if
|
||||
tilelang is missing (the image provides it).
|
||||
|
||||
## Run as a self-hosted runner
|
||||
|
||||
`entrypoint.sh` registers an ephemeral runner (one job per container), then deregisters on
|
||||
exit. Provide a registration token and the target URL; bind-mount the host cache. The
|
||||
entrypoint removes `RUNNER_TOKEN` from the environment before the runner starts, so jobs
|
||||
cannot read the registration token.
|
||||
|
||||
```bash
|
||||
docker run -d --gpus all \
|
||||
-e RUNNER_URL=https://github.com/tile-ai/TileOPs \
|
||||
-e RUNNER_TOKEN=<registration-token> \
|
||||
-e RUNNER_LABELS=self-hosted,tile-ops,venv \
|
||||
-v <host-cache-dir>:/ci-cache \
|
||||
ghcr.io/tile-ai/tileops-runner:65dbc98
|
||||
```
|
||||
|
||||
The image sets cache env vars (`TILELANG_CACHE_DIR`, `TRITON_CACHE_DIR`, `PIP_CACHE_DIR`, …)
|
||||
under `/ci-cache`; the directories are pre-created so the container also works unmounted.
|
||||
|
||||
## Bumping the tilelang commit
|
||||
|
||||
A commit (or release) bump always rebuilds, but **never edits the Dockerfile**: rebuild with a
|
||||
new `--build-arg TILELANG_GIT_SHA=<commit>` (or `TILELANG_VERSION=<version>`) and a new
|
||||
`:<short-sha>` tag, push to `ghcr.io`, then point the runner at the new tag. Because tilelang
|
||||
is the last stage, only its layer recompiles — the bench layers (FA2 / FA3 / vLLM / …) stay
|
||||
cached. Switching between a release and a main commit is the same — only the build-arg and tag
|
||||
change.
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Group-writable (0775 dirs / 0664 files) so compiled kernels in the shared /ci-cache are
|
||||
# reusable across runner UIDs that share the ci-runner group.
|
||||
umask 002
|
||||
|
||||
# Ad-hoc command passthrough: `docker run <image> python ...` / `bash -c ...` runs the
|
||||
# given command directly (image verification, smoke tests; see README). With no args the
|
||||
# container registers an ephemeral self-hosted runner (below).
|
||||
if [ "$#" -gt 0 ]; then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# ── Validate required environment variables ──────────────────────────────────
|
||||
: "${RUNNER_TOKEN:?Environment variable RUNNER_TOKEN is required}"
|
||||
: "${RUNNER_URL:?Environment variable RUNNER_URL is required}"
|
||||
|
||||
# Jobs run as children of the listener and inherit its environment; a job that reads
|
||||
# RUNNER_TOKEN can register a rogue runner with trusted labels while the token is valid.
|
||||
# Hold the token in an unexported shell variable and drop it from the environment before
|
||||
# anything else runs. unset first: if reg_token arrived exported from the container
|
||||
# environment, plain assignment would keep the export attribute and leak the token anyway.
|
||||
unset reg_token
|
||||
reg_token="${RUNNER_TOKEN}"
|
||||
unset RUNNER_TOKEN
|
||||
|
||||
RUNNER_NAME="${RUNNER_NAME:-$(hostname)}"
|
||||
RUNNER_LABELS="${RUNNER_LABELS:-self-hosted,tile-ops,venv}"
|
||||
RUNNER_WORKDIR="${RUNNER_WORKDIR:-_work}"
|
||||
|
||||
# ── Cleanup function — deregister runner on exit ─────────────────────────────
|
||||
cleanup() {
|
||||
echo "Removing runner registration..."
|
||||
./config.sh remove --token "${reg_token}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# ── Configure the runner (ephemeral: one job per lifecycle) ──────────────────
|
||||
./config.sh \
|
||||
--url "${RUNNER_URL}" \
|
||||
--token "${reg_token}" \
|
||||
--name "${RUNNER_NAME}" \
|
||||
--labels "${RUNNER_LABELS}" \
|
||||
--work "${RUNNER_WORKDIR}" \
|
||||
--ephemeral \
|
||||
--replace \
|
||||
--unattended \
|
||||
--disableupdate
|
||||
|
||||
# ── Run one job, then exit ───────────────────────────────────────────────────
|
||||
# Make container stop signals reach the runner so an idle ephemeral runner shuts down promptly
|
||||
# instead of waiting out docker stop's grace:
|
||||
# - RUNNER_MANUALLY_TRAP_SIG=1 makes run.sh trap SIGTERM/SIGINT and forward SIGINT to the
|
||||
# listener process group (its default mode does not trap, so the signal is otherwise lost).
|
||||
# - exec makes run.sh PID 1 so docker stop's SIGTERM is delivered to it; a bash wrapper as
|
||||
# PID 1 would ignore an untrapped SIGTERM and stall until SIGKILL.
|
||||
# Trade-off: a stop signal cancels an in-progress job rather than draining it.
|
||||
export RUNNER_MANUALLY_TRAP_SIG=1
|
||||
exec ./run.sh
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
name: auto-label
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
apply-label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Skip non-title edits
|
||||
id: guard
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_PATH: ${{ github.event_path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EVENT_ACTION" != "edited" ]]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! jq -e '.changes | has("title")' "$EVENT_PATH" >/dev/null; then
|
||||
echo "Body-only edit detected; skipping label sync."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v4
|
||||
if: ${{ steps.guard.outputs.should_run == 'true' }}
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha || github.sha }}
|
||||
sparse-checkout: .claude/conventions
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Extract type from title and apply label
|
||||
if: ${{ steps.guard.outputs.should_run == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
source .claude/conventions/types.sh
|
||||
|
||||
LABEL=""
|
||||
if [ "$EVENT_NAME" = "issues" ]; then
|
||||
TITLE="$ISSUE_TITLE"
|
||||
NUMBER="$ISSUE_NUMBER"
|
||||
GH_CMD="issue"
|
||||
else
|
||||
TITLE="$PR_TITLE"
|
||||
NUMBER="$PR_NUMBER"
|
||||
GH_CMD="pr"
|
||||
fi
|
||||
|
||||
# Try commit/PR-style types first (e.g. [BugFix])
|
||||
TYPE=$(echo "$TITLE" | grep -oP "^\[\K(${COMMIT_PR_TYPES})(?=\])" || true)
|
||||
|
||||
if [ -n "$TYPE" ]; then
|
||||
LABEL="${TYPE_TO_LABEL[$TYPE]:-}"
|
||||
elif [ "$EVENT_NAME" = "issues" ]; then
|
||||
# Fall back to ALL-CAPS issue types (e.g. [BUG])
|
||||
TYPE=$(echo "$TITLE" | grep -oP "^\[\K(${ISSUE_TYPES})(?=\])" || true)
|
||||
if [ -n "$TYPE" ]; then
|
||||
LABEL="${ISSUE_TYPE_TO_LABEL[$TYPE]:-}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$TYPE" ] || [ -z "$LABEL" ]; then
|
||||
echo "No recognized type tag in title, skipping label"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Detected type: $TYPE -> label: $LABEL"
|
||||
|
||||
# Check if correct label already applied
|
||||
CURRENT_LABELS=$(gh "$GH_CMD" view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels --jq '.labels[].name')
|
||||
if echo "$CURRENT_LABELS" | grep -qx "$LABEL"; then
|
||||
echo "Label '$LABEL' already applied, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Remove any stale type labels
|
||||
for l in $ALL_TYPE_LABELS; do
|
||||
if echo "$CURRENT_LABELS" | grep -qx "$l"; then
|
||||
gh "$GH_CMD" edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "$l"
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure label exists (create if missing)
|
||||
if ! gh label list --repo "$GITHUB_REPOSITORY" --search "$LABEL" --json name --jq '.[].name' | grep -qx "$LABEL"; then
|
||||
gh label create "$LABEL" --repo "$GITHUB_REPOSITORY" --description "Auto-created by labeler" --force
|
||||
echo "Created label: $LABEL"
|
||||
fi
|
||||
|
||||
# Apply the matching label
|
||||
gh "$GH_CMD" edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "$LABEL"
|
||||
echo "Applied label: $LABEL"
|
||||
|
|
@ -1,657 +0,0 @@
|
|||
name: GPU Smoke
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testbed]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [dev, testbed]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
ci-prereq:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.check.outputs.skip }}
|
||||
steps:
|
||||
- name: Wait for CI prerequisites
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_ACTION: ${{ github.event.action || '' }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft || false }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EVENT_NAME" != "pull_request" ]]; then
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$IS_DRAFT" == "true" ]]; then
|
||||
echo "Draft PR; skipping GPU smoke."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
required_checks=(pre-commit gitleaks actionlint)
|
||||
deadline=$((SECONDS + 900))
|
||||
|
||||
while true; do
|
||||
checks_json=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs")
|
||||
latest_checks=$(echo "$checks_json" | jq '
|
||||
.check_runs
|
||||
| group_by(.name)
|
||||
| map(max_by(.id))
|
||||
')
|
||||
|
||||
pending="false"
|
||||
failed=""
|
||||
|
||||
for check_name in "${required_checks[@]}"; do
|
||||
status=$(echo "$latest_checks" | jq -r --arg name "$check_name" '
|
||||
map(select(.name == $name)) | first | .status // ""
|
||||
')
|
||||
conclusion=$(echo "$latest_checks" | jq -r --arg name "$check_name" '
|
||||
map(select(.name == $name)) | first | .conclusion // ""
|
||||
')
|
||||
|
||||
if [[ -z "$status" || "$status" != "completed" ]]; then
|
||||
pending="true"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$conclusion" == "skipped" ]]; then
|
||||
pending="true"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
failed="$check_name"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
title_status=$(echo "$latest_checks" | jq -r '
|
||||
map(select(.name == "validate-pr-title")) | first | .status // ""
|
||||
')
|
||||
title_conclusion=$(echo "$latest_checks" | jq -r '
|
||||
map(select(.name == "validate-pr-title")) | first | .conclusion // ""
|
||||
')
|
||||
if [[ -n "$title_status" && "$title_status" == "completed" && "$title_conclusion" != "success" && "$title_conclusion" != "skipped" ]]; then
|
||||
failed="validate-pr-title"
|
||||
elif [[ -n "$title_status" && "$title_status" != "completed" ]]; then
|
||||
pending="true"
|
||||
fi
|
||||
|
||||
if [[ -n "$failed" ]]; then
|
||||
echo "CI prerequisite failed: $failed"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$pending" == "false" ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "Timed out waiting for CI prerequisites."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [[ "$EVENT_ACTION" == "ready_for_review" ]]; then
|
||||
CHECKS=$(echo "$latest_checks" | jq '
|
||||
[
|
||||
.[]
|
||||
| select(.name == "gpu-smoke")
|
||||
| .conclusion
|
||||
]
|
||||
')
|
||||
ALL_SUCCESS=$(echo "$CHECKS" | jq 'all(. == "success")')
|
||||
COUNT=$(echo "$CHECKS" | jq 'length')
|
||||
if [[ "$ALL_SUCCESS" == "true" && "$COUNT" -ge 1 ]]; then
|
||||
echo "GPU smoke already passed for SHA $HEAD_SHA; skipping"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
security-policy:
|
||||
needs: ci-prereq
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && needs.ci-prereq.outputs.skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_fork: ${{ steps.policy.outputs.is_fork }}
|
||||
allow_fast_path: ${{ steps.policy.outputs.allow_fast_path }}
|
||||
skip_gpu_smoke: ${{ steps.policy.outputs.skip_gpu_smoke }}
|
||||
scope: ${{ steps.policy.outputs.scope }}
|
||||
pytest_targets: ${{ steps.policy.outputs.pytest_targets }}
|
||||
full_tier_targets: ${{ steps.policy.outputs.full_tier_targets }}
|
||||
reason: ${{ steps.policy.outputs.reason }}
|
||||
steps:
|
||||
- name: Checkout test files
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
# Whole tests/ tree: the policy step existence-checks changed
|
||||
# test files before selecting them.
|
||||
sparse-checkout: |
|
||||
tests
|
||||
|
||||
- name: Determine GPU policy and pytest scope
|
||||
id: policy
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
add_target() {
|
||||
local target="$1"
|
||||
[[ -e "$target" ]] || return 0
|
||||
if [[ -z "${seen_targets[$target]:-}" ]]; then
|
||||
selected_targets+=("$target")
|
||||
seen_targets["$target"]=1
|
||||
fi
|
||||
}
|
||||
|
||||
is_fork="false"
|
||||
allow_fast_path="true"
|
||||
skip_gpu_smoke="false"
|
||||
scope="full-smoke"
|
||||
pytest_targets="tests"
|
||||
full_tier_targets=""
|
||||
reason="GPU smoke will run."
|
||||
|
||||
# `is_fork` is the trust boundary that routes runs-on (resident shared-cache pool vs
|
||||
# overlay-isolated fork pool) and the trusted-action ref below. Derive it from the PR
|
||||
# author's collaborator permission on the base repo, NOT author_association: private org
|
||||
# membership can report a non-member association (would wrongly isolate trusted authors),
|
||||
# and COLLABORATOR is not proof of write access (would wrongly trust read-only authors).
|
||||
# Only write/maintain/admin are trusted; everyone else — and any lookup failure — fails
|
||||
# closed to the fork pool.
|
||||
if [[ "$EVENT_NAME" != "pull_request" ]]; then
|
||||
is_fork="false" # push / schedule / workflow_dispatch are trusted
|
||||
elif [[ "$HEAD_REPO" == "$BASE_REPO" ]]; then
|
||||
is_fork="false" # same-repo branch PR: author already has base-repo write access
|
||||
else
|
||||
role=$(gh api "repos/${BASE_REPO}/collaborators/${PR_AUTHOR}/permission" \
|
||||
--jq '.role_name // .permission' 2>/dev/null || echo "")
|
||||
case "$role" in
|
||||
admin|maintain|write)
|
||||
is_fork="false"
|
||||
echo "Author ${PR_AUTHOR} has '${role}' on ${BASE_REPO}; routing to resident pool."
|
||||
;;
|
||||
*)
|
||||
is_fork="true"
|
||||
echo "Author ${PR_AUTHOR} permission='${role:-unknown}'; routing to fork pool (fail-closed)."
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
mapfile -t changed_files < <(
|
||||
gh api "repos/${BASE_REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename'
|
||||
)
|
||||
|
||||
echo "Changed files (${#changed_files[@]}):"
|
||||
printf ' - %s\n' "${changed_files[@]}"
|
||||
|
||||
# Changed test files (basename test_*.py, existing at head);
|
||||
# the PR gate runs these under -m "smoke or full".
|
||||
declare -A seen_full_targets=()
|
||||
full_targets_list=()
|
||||
for path in "${changed_files[@]}"; do
|
||||
case "$path" in
|
||||
tests/*/test_*.py|tests/test_*.py)
|
||||
if [[ -e "$path" && -z "${seen_full_targets[$path]:-}" ]]; then
|
||||
full_targets_list+=("$path")
|
||||
seen_full_targets["$path"]=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
full_tier_targets="${full_targets_list[*]}"
|
||||
|
||||
# Fast path: if no Python files changed, GPU smoke is unnecessary.
|
||||
# For fork PRs, also check for high-risk files (workflow, build
|
||||
# config, etc.) that must still trigger GPU smoke even without
|
||||
# Python changes to avoid security bypass.
|
||||
has_python="false"
|
||||
has_high_risk="false"
|
||||
for path in "${changed_files[@]}"; do
|
||||
if [[ "$path" == *.py ]]; then
|
||||
has_python="true"
|
||||
fi
|
||||
if [[ "$is_fork" == "true" ]]; then
|
||||
case "$path" in
|
||||
.github/workflows/*|pyproject.toml|requirements*.txt|requirements*.in|setup.py|setup.cfg|Dockerfile*|scripts/bootstrap*|scripts/install*)
|
||||
has_high_risk="true"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
if [[ "$has_python" == "false" && "$has_high_risk" == "false" ]]; then
|
||||
skip_gpu_smoke="true"
|
||||
reason="No Python files changed; GPU smoke skipped."
|
||||
{
|
||||
echo "skip_gpu_smoke=$skip_gpu_smoke"
|
||||
echo "is_fork=$is_fork"
|
||||
echo "allow_fast_path=$allow_fast_path"
|
||||
echo "scope=skip"
|
||||
echo "pytest_targets="
|
||||
echo "full_tier_targets="
|
||||
echo "reason=$reason"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$has_python" == "false" && "$has_high_risk" == "true" ]]; then
|
||||
# Fork PR changed high-risk build/infra files but no Python.
|
||||
# Run full GPU smoke to validate the environment is not broken.
|
||||
skip_gpu_smoke="false"
|
||||
allow_fast_path="false"
|
||||
scope="full-smoke"
|
||||
pytest_targets="tests"
|
||||
reason="Fork PR changed high-risk file(s) without Python changes; running full GPU smoke."
|
||||
{
|
||||
echo "skip_gpu_smoke=$skip_gpu_smoke"
|
||||
echo "is_fork=$is_fork"
|
||||
echo "allow_fast_path=$allow_fast_path"
|
||||
echo "scope=$scope"
|
||||
echo "pytest_targets=$pytest_targets"
|
||||
echo "full_tier_targets=$full_tier_targets"
|
||||
echo "reason=$reason"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
scope="skip"
|
||||
declare -A seen_targets=()
|
||||
selected_targets=()
|
||||
|
||||
for path in "${changed_files[@]}"; do
|
||||
if [[ "$is_fork" == "true" ]]; then
|
||||
case "$path" in
|
||||
.github/ISSUE_TEMPLATE/*|.github/pull_request_template.md|tileops/ops/*.py|tests/ops/test_*.py|*.md)
|
||||
;;
|
||||
.github/workflows/*|pyproject.toml|requirements*.txt|requirements*.in|setup.py|setup.cfg|Dockerfile*|scripts/bootstrap*|scripts/install*)
|
||||
allow_fast_path="false"
|
||||
reason="Fork PR changed a high-risk file: $path"
|
||||
;;
|
||||
*)
|
||||
allow_fast_path="false"
|
||||
reason="Fork PR changed a file outside the future fast-path policy: $path"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$path" in
|
||||
.github/ISSUE_TEMPLATE/*|.github/pull_request_template.md|.github/workflows/auto-label.yml|.github/workflows/stale-issues.yml|*.md)
|
||||
;;
|
||||
tests/ops/test_*.py)
|
||||
add_target "$path"
|
||||
if [[ "$scope" == "skip" ]]; then
|
||||
scope="targeted"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
scope="full-smoke"
|
||||
reason="Shared or unsupported path $path; falling back to full smoke."
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$scope" == "skip" ]]; then
|
||||
if [[ "$allow_fast_path" == "true" ]]; then
|
||||
# All changed files matched docs/metadata patterns; skip GPU smoke
|
||||
# for both trusted and fork PRs.
|
||||
skip_gpu_smoke="true"
|
||||
pytest_targets=""
|
||||
reason="Docs-only or metadata-only change; GPU smoke skipped."
|
||||
elif [[ "$is_fork" == "true" ]]; then
|
||||
scope="full-smoke"
|
||||
pytest_targets="tests"
|
||||
reason="${reason}; current policy still runs GPU smoke."
|
||||
else
|
||||
skip_gpu_smoke="true"
|
||||
pytest_targets=""
|
||||
reason="Docs-only or metadata-only change; GPU smoke skipped."
|
||||
fi
|
||||
elif [[ "$scope" == "targeted" ]]; then
|
||||
if [[ "${#selected_targets[@]}" -eq 0 ]]; then
|
||||
scope="full-smoke"
|
||||
pytest_targets="tests"
|
||||
reason="No mapped tests were collected; falling back to full smoke."
|
||||
else
|
||||
pytest_targets="${selected_targets[*]}"
|
||||
if [[ "$is_fork" == "true" && "$allow_fast_path" != "true" ]]; then
|
||||
reason="${reason}; current policy still runs targeted GPU smoke."
|
||||
else
|
||||
reason="Running targeted GPU smoke tests."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "is_fork=$is_fork"
|
||||
echo "allow_fast_path=$allow_fast_path"
|
||||
echo "skip_gpu_smoke=$skip_gpu_smoke"
|
||||
echo "scope=$scope"
|
||||
echo "pytest_targets=$pytest_targets"
|
||||
echo "full_tier_targets=$full_tier_targets"
|
||||
echo "reason=$reason"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
gpu-smoke:
|
||||
needs: [ci-prereq, security-policy]
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && needs.ci-prereq.outputs.skip != 'true' && needs.security-policy.outputs.skip_gpu_smoke != 'true' }}
|
||||
# Trust-level routing: untrusted (external) PRs go to the on-demand `fork` pool whose
|
||||
# runner mounts an overlay cache (read-only shared lower + throwaway upper) so their cache
|
||||
# writes never reach the shared cache. Trusted runs use the resident shared-cache pool.
|
||||
runs-on: tileops-metax-runner
|
||||
# Hard backstop so a wedged kernel can never hold the single runner indefinitely; the
|
||||
# per-test --timeout below is the first line of defense, this is the ceiling.
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout code for fork PR
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Checkout code for trusted branch
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
# Trusted actions checkout: must run AFTER the workspace checkouts so
|
||||
# their `git clean -ffdx` pass does not wipe `.trusted/`. Anything
|
||||
# loaded via `uses: ./...` from here on MUST resolve through
|
||||
# `.trusted/` so that fork-controlled PR code cannot substitute the
|
||||
# composite action.
|
||||
#
|
||||
# Ref selection reuses the single `is_fork` trust result computed in security-policy
|
||||
# (collaborator-permission based), so it stays consistent with runs-on:
|
||||
#
|
||||
# - untrusted (is_fork == true: external author or lookup failure)
|
||||
# → `base.sha` (authoritative; the untrusted author cannot alter it)
|
||||
# - trusted (same-repo PR, write+ member-fork PR, or push)
|
||||
# → head sha (author already has write access to the base repo)
|
||||
- name: Checkout trusted actions (base repo)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ needs.security-policy.outputs.is_fork == 'true' && github.event.pull_request.base.sha || github.event.pull_request.head.sha || github.sha }}
|
||||
path: .trusted
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
sparse-checkout: |
|
||||
.github/actions
|
||||
|
||||
- name: Reclaim runner disk
|
||||
uses: ./.trusted/.github/actions/reclaim-runner-disk
|
||||
with:
|
||||
reclaim-below-gib: "10"
|
||||
cache-trim-cooldown-minutes: "120"
|
||||
cache-dirs: |
|
||||
/ci-cache/triton
|
||||
/ci-cache/pip
|
||||
/ci-cache/tilelang/tmp
|
||||
atomic-cache-dirs: |
|
||||
/ci-cache/tilelang/autotuner
|
||||
df-paths: "/ci-cache"
|
||||
# Daily runner-maintenance.yml owns the destructive autotuner
|
||||
# age-trim; per-PR runs only need sentinel-repair (still
|
||||
# unconditional) so they don't evict tuning artefacts the next
|
||||
# PR would otherwise reuse.
|
||||
skip-atomic-age-trim: "true"
|
||||
|
||||
- name: Validate GPU frequency
|
||||
if: ${{ github.repository == 'tile-ai/TileOPs' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_CLOCK_MHZ=1500
|
||||
RETRIES=5
|
||||
SLEEP_SECONDS=2
|
||||
|
||||
if ! command -v nvidia-smi >/dev/null 2>&1; then
|
||||
echo "::error::nvidia-smi is not available on this runner."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Detected GPUs:"
|
||||
nvidia-smi -L
|
||||
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$RETRIES" ]; do
|
||||
echo "Validation attempt ${attempt}/${RETRIES}"
|
||||
mismatch=0
|
||||
while IFS=',' read -r gpu_index gpu_name gpu_clock; do
|
||||
gpu_index="$(echo "$gpu_index" | xargs)"
|
||||
gpu_name="$(echo "$gpu_name" | xargs)"
|
||||
gpu_clock="$(echo "$gpu_clock" | xargs)"
|
||||
echo "GPU ${gpu_index} (${gpu_name}) clock: ${gpu_clock} MHz"
|
||||
if [[ "$gpu_clock" != "$TARGET_CLOCK_MHZ" ]]; then
|
||||
mismatch=1
|
||||
fi
|
||||
done < <(nvidia-smi --query-gpu=index,name,clocks.current.graphics --format=csv,noheader,nounits)
|
||||
|
||||
if [[ "$mismatch" -eq 0 ]]; then
|
||||
echo "All GPUs locked at ${TARGET_CLOCK_MHZ} MHz."
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$attempt" -eq "$RETRIES" ]]; then
|
||||
echo "::error::GPU frequency validation failed. Expected ${TARGET_CLOCK_MHZ} MHz on all GPUs."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep "$SLEEP_SECONDS"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
- name: Install tilelang-metax
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' }}
|
||||
run: bash scripts/ci/install_tilelang.sh
|
||||
|
||||
- name: Install TileOPs (image-baked stack, --no-deps)
|
||||
# tilelang + the full runtime/dev stack are baked into the runner image; this installs
|
||||
# only tileops itself (editable, --no-deps) against constraints.txt. Fork write
|
||||
# isolation is provided by the `fork` runner pool's overlay cache, not the workflow.
|
||||
run: bash scripts/ci/install_tileops.sh
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TEST_SCOPE: ${{ needs.security-policy.outputs.scope }}
|
||||
PYTEST_TARGETS: ${{ needs.security-policy.outputs.pytest_targets }}
|
||||
FULL_TIER_TARGETS: ${{ needs.security-policy.outputs.full_tier_targets }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Per-test hang guard: a stuck kernel (e.g. a cold-compile loop or a spinning CUDA
|
||||
# call) fails as a timeout with a thread dump instead of wedging the job. --no-deps
|
||||
# tileops install doesn't bring this; pin via constraints.txt so the install is
|
||||
# deterministic (the runner image should bake it and drop this install — follow-up).
|
||||
python3 -m pip install -q -c constraints.txt pytest-timeout
|
||||
current_dir="$(pwd)"
|
||||
export PYTHONPATH="${current_dir}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
echo "PYTHONPATH=$PYTHONPATH"
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
|
||||
print("Runtime cache env:")
|
||||
for key in ("TILELANG_CACHE_DIR", "TILELANG_TMP_DIR", "TRITON_CACHE_DIR"):
|
||||
print(f"{key}={os.environ.get(key)}")
|
||||
PY
|
||||
|
||||
TEST_MARK_EXPR="smoke or full"
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
TEST_MARK_EXPR="smoke"
|
||||
fi
|
||||
|
||||
echo "Resolved test scope: ${TEST_SCOPE}"
|
||||
echo "Resolved pytest targets: ${PYTEST_TARGETS}"
|
||||
echo "Diff-scoped full-tier targets: ${FULL_TIER_TARGETS:-<none>}"
|
||||
read -r -a TARGETS <<< "${PYTEST_TARGETS}"
|
||||
read -r -a FULL_TARGETS <<< "${FULL_TIER_TARGETS:-}"
|
||||
|
||||
# Diff-scoped full-tier PR gate: changed test files run
|
||||
# -m "smoke or full"; everything else stays smoke-only for latency.
|
||||
# Residual gap (accepted): a PR breaking full-tier cases in files it
|
||||
# does not touch is only caught by the push-to-main run — closing it
|
||||
# would cost a full suite per PR. Push-tier failure triage: bisect
|
||||
# PRs merged since the last green push run.
|
||||
if [[ "$EVENT_NAME" == "pull_request" && "${#FULL_TARGETS[@]}" -gt 0 ]]; then
|
||||
if [[ "$TEST_SCOPE" == "targeted" ]]; then
|
||||
# Targeted scope already selects exactly these files; promote
|
||||
# the single pass instead of running twice.
|
||||
TEST_MARK_EXPR="smoke or full"
|
||||
FULL_TARGETS=()
|
||||
else
|
||||
echo "Running diff-scoped pytest -m \"smoke or full\" on ${#FULL_TARGETS[@]} changed test file(s)"
|
||||
diff_status=0
|
||||
python3 -m pytest -q "${FULL_TARGETS[@]}" -m "smoke or full" \
|
||||
--timeout=600 --timeout-method=thread \
|
||||
--junit-xml=gpu_smoke_full_results.xml | tee gpu_smoke_full.log \
|
||||
|| diff_status=$?
|
||||
# Exit 5 = no tests collected; not a failure.
|
||||
if [[ "$diff_status" -ne 0 && "$diff_status" -ne 5 ]]; then
|
||||
exit "$diff_status"
|
||||
fi
|
||||
# Already ran above; exclude from the smoke pass.
|
||||
for full_target in "${FULL_TARGETS[@]}"; do
|
||||
TARGETS+=("--ignore=${full_target}")
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Marker the baseline pass actually executes; the report step
|
||||
# labels the tier from it.
|
||||
echo "EXECUTED_MARK_EXPR=${TEST_MARK_EXPR}" >> "$GITHUB_ENV"
|
||||
|
||||
echo "Running pytest -m \"${TEST_MARK_EXPR}\" on ${#TARGETS[@]} target(s)"
|
||||
python3 -m pytest -q "${TARGETS[@]}" -m "${TEST_MARK_EXPR}" \
|
||||
--timeout=600 --timeout-method=thread \
|
||||
--junit-xml=gpu_smoke_results.xml | tee gpu_smoke.log
|
||||
|
||||
- name: Generate gpu-smoke report
|
||||
if: ${{ always() && (hashFiles('gpu_smoke_results.xml') != '' || hashFiles('gpu_smoke_full_results.xml') != '') }}
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Set by Run tests once the tier is resolved; fall back to the
|
||||
# event default only if Run tests died before resolving it.
|
||||
if [[ -n "${EXECUTED_MARK_EXPR:-}" ]]; then
|
||||
REPORT_TARGET="${EXECUTED_MARK_EXPR// or /, }"
|
||||
else
|
||||
REPORT_TARGET="smoke, full"
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
REPORT_TARGET="smoke"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f gpu_smoke_results.xml ]]; then
|
||||
python3 scripts/gpu_smoke_report.py \
|
||||
--test-xml gpu_smoke_results.xml \
|
||||
--target "${REPORT_TARGET}" \
|
||||
--output gpu_smoke_main_report.md \
|
||||
|| echo "::warning::gpu_smoke_report.py failed"
|
||||
fi
|
||||
|
||||
if [[ -f gpu_smoke_full_results.xml ]]; then
|
||||
python3 scripts/gpu_smoke_report.py \
|
||||
--test-xml gpu_smoke_full_results.xml \
|
||||
--target "smoke, full (diff-scoped changed test files)" \
|
||||
--output gpu_smoke_diff_report.md \
|
||||
|| echo "::warning::gpu_smoke_report.py failed"
|
||||
fi
|
||||
|
||||
for part in gpu_smoke_main_report.md gpu_smoke_diff_report.md; do
|
||||
if [[ -f "$part" ]]; then
|
||||
cat "$part" >> gpu_smoke_report.md
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Post gpu-smoke report to step summary
|
||||
if: ${{ always() && hashFiles('gpu_smoke_report.md') != '' }}
|
||||
run: cat gpu_smoke_report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cache stats
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
IS_FORK: ${{ needs.security-policy.outputs.is_fork }}
|
||||
run: |
|
||||
# Cache stats is informational only. Do NOT use `set -e`: a transient
|
||||
# failure in find/du/wc (e.g. a cache file vanishing mid-scan due to a
|
||||
# concurrent cleanup) must not fail the gpu-smoke job.
|
||||
set -u
|
||||
echo "=== Cache stats (is_fork=${IS_FORK}) ==="
|
||||
for cache_dir in "${TILELANG_CACHE_DIR:-}" "${TRITON_CACHE_DIR:-}" "${PIP_CACHE_DIR:-}"; do
|
||||
if [[ -z "${cache_dir}" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -d "${cache_dir}" ]]; then
|
||||
# Tolerate errors from find/du/wc; emit "unknown" when a scan
|
||||
# fails rather than aborting the step. Enable pipefail inside
|
||||
# the subshell so a find/du failure upstream of wc/cut
|
||||
# propagates to the || fallback instead of being masked.
|
||||
file_count=$(set -o pipefail; find "${cache_dir}" -type f 2>/dev/null | wc -l 2>/dev/null) || file_count="unknown"
|
||||
cache_size=$(set -o pipefail; du -sh "${cache_dir}" 2>/dev/null | cut -f1 2>/dev/null) || cache_size="unknown"
|
||||
if [[ -z "${file_count}" ]]; then file_count="unknown"; fi
|
||||
if [[ -z "${cache_size}" ]]; then cache_size="unknown"; fi
|
||||
echo "${cache_dir}: ${file_count} files, ${cache_size}"
|
||||
else
|
||||
echo "${cache_dir}: does not exist"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: gpu_smoke.log
|
||||
path: |
|
||||
gpu_smoke.log
|
||||
gpu_smoke_results.xml
|
||||
gpu_smoke_full.log
|
||||
gpu_smoke_full_results.xml
|
||||
gpu_smoke_report.md
|
||||
retention-days: 7
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
name: Manifest Stats
|
||||
|
||||
permissions:
|
||||
contents: write # publish-stats job pushes to the `stats` branch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
schedule:
|
||||
# Daily fallback in case a push event was missed.
|
||||
- cron: "17 6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# All publish-capable events (push, schedule, workflow_dispatch) share a
|
||||
# single mutex so a slower scheduled run can never overwrite a newer push.
|
||||
# PRs are isolated per number.
|
||||
concurrency:
|
||||
group: >-
|
||||
${{ github.event_name == 'pull_request'
|
||||
&& format('manifest-stats-pr-{0}', github.event.pull_request.number)
|
||||
|| 'manifest-stats-publish' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Manifest stats summary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install minimal dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Generate current stats
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _stats
|
||||
python scripts/manifest_stats.py --format json --output _stats/current.json
|
||||
python scripts/manifest_stats.py --format md --output _stats/current.md
|
||||
|
||||
- name: Compute diff vs base branch (PR only)
|
||||
id: diff
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --depth=1 origin "$BASE_SHA"
|
||||
git worktree add /tmp/base "$BASE_SHA"
|
||||
# Use the PR-branch script (it may not exist on base) but load
|
||||
# tileops.manifest from the base worktree so the snapshot reflects
|
||||
# the base ref's manifest contents.
|
||||
PYTHONPATH=/tmp/base python scripts/manifest_stats.py \
|
||||
--format json --output /tmp/base-stats.json
|
||||
git worktree remove --force /tmp/base
|
||||
|
||||
PYTHONPATH=${{ github.workspace }} python scripts/manifest_stats.py \
|
||||
--format md --diff /tmp/base-stats.json --output _stats/comment.md
|
||||
|
||||
- name: Write Job Summary
|
||||
run: |
|
||||
if [[ -f _stats/comment.md ]]; then
|
||||
cat _stats/comment.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
cat _stats/current.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload stats artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: manifest-stats
|
||||
path: _stats/
|
||||
retention-days: 14
|
||||
|
||||
publish-stats:
|
||||
name: Publish stats to orphan branch
|
||||
needs: report
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install minimal dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Generate publish payload
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _publish
|
||||
python scripts/manifest_stats.py --format json --output _publish/manifest-stats.json
|
||||
python scripts/manifest_stats.py --format md --output _publish/manifest-stats.md
|
||||
python scripts/manifest_stats.py --badge-output _publish
|
||||
|
||||
- name: Push to stats branch
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_branch: stats
|
||||
publish_dir: _publish
|
||||
force_orphan: true
|
||||
commit_message: "chore(stats): refresh manifest stats from ${{ github.sha }}"
|
||||
user_name: "github-actions[bot]"
|
||||
user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
name: Nightly
|
||||
|
||||
permissions: read-all
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
|
||||
concurrency:
|
||||
group: nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# All nightly jobs run on a containerized self-hosted runner (label: nightly).
|
||||
# The runner container is provisioned by the host with GPU access, persistent
|
||||
# cache mounts, and the runtime environment variables below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
env:
|
||||
TILELANG_CACHE_DIR: /ci-cache/tilelang
|
||||
TILELANG_TMP_DIR: /ci-cache/tilelang/tmp
|
||||
TRITON_CACHE_DIR: /ci-cache/triton
|
||||
PIP_CACHE_DIR: /ci-cache/pip
|
||||
MAX_JOBS: "64"
|
||||
CCACHE_DIR: /ci-cache/ccache
|
||||
PYTHONPATH: /ci-cache/site-packages
|
||||
|
||||
jobs:
|
||||
# =========================================================================
|
||||
# Phase 1 — Benchmark (exclusive GPU access for accurate profiling)
|
||||
#
|
||||
# The persistent cache (TILELANG_CACHE_DIR) carries compiled kernels and
|
||||
# autotuner results across runs; benchmark setup repopulates it on a cold key
|
||||
# (e.g. after a tilelang bump). No separate warmup phase. For a one-off cold
|
||||
# cache, run scripts/warmup_kernel_cache.py manually.
|
||||
# =========================================================================
|
||||
benchmark:
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && (github.event_name == 'schedule' || github.ref == 'refs/heads/dev') }}
|
||||
timeout-minutes: 120
|
||||
runs-on: tileops-metax-runner
|
||||
env:
|
||||
# Keep the long benchmark suite from fragmenting CUDA allocator segments
|
||||
# before late large MoE input tensors allocate 14-21 GiB weight blocks.
|
||||
PYTORCH_ALLOC_CONF: expandable_segments:True
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Verify nightly runner environment
|
||||
run: scripts/ci/verify_nightly_runner.sh
|
||||
shell: bash
|
||||
|
||||
- name: Install TileOPs (image-baked stack, --no-deps)
|
||||
run: bash scripts/ci/install_tileops.sh
|
||||
shell: bash
|
||||
|
||||
- name: Run benchmark ops
|
||||
id: benchmark_tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PYTHONPATH="${GITHUB_WORKSPACE}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
echo "Runtime cache env:"
|
||||
echo "TILELANG_CACHE_DIR=${TILELANG_CACHE_DIR:-}"
|
||||
echo "TILELANG_TMP_DIR=${TILELANG_TMP_DIR:-}"
|
||||
echo "TRITON_CACHE_DIR=${TRITON_CACHE_DIR:-}"
|
||||
|
||||
set -o pipefail
|
||||
python3 -m pip install -q -c constraints.txt pytest-timeout
|
||||
python3 -m pytest -q benchmarks/ops --timeout=900 --timeout-method=thread --junit-xml=bench_results.xml | tee tileops_benchmarks.log
|
||||
|
||||
if [ -f profile_run.log ]; then
|
||||
{ echo; echo "===== profile_run.log summary ====="; cat profile_run.log; } >> tileops_benchmarks.log
|
||||
else
|
||||
echo "::warning::profile_run.log not found; benchmark may have failed partially" >> tileops_benchmarks.log
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Upload benchmark artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: tileops_benchmark_${{ github.run_id }}
|
||||
path: |
|
||||
tileops_benchmarks.log
|
||||
bench_results.xml
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail benchmark job if pytest failed
|
||||
if: ${{ always() && steps.benchmark_tests.outcome == 'failure' }}
|
||||
run: exit 1
|
||||
shell: bash
|
||||
|
||||
# =========================================================================
|
||||
# Phase 2 — Op correctness tests (serial after benchmark for GPU exclusivity)
|
||||
#
|
||||
# Runs the `full` and `nightly` tiers. The `smoke` tier is the PR critical
|
||||
# path and is already validated by every PR and every push-to-main gpu-smoke
|
||||
# (-m "smoke or full"), so re-running it here is pure duplication. `nightly`
|
||||
# is the heavy tier that runs nowhere else; `full` stays as a scheduled
|
||||
# regression / environment-drift net.
|
||||
# =========================================================================
|
||||
op_test:
|
||||
if: ${{ always() && github.repository == 'MetaX-MACA/TileOPs-Metax' && (github.event_name == 'schedule' || github.ref == 'refs/heads/dev') }}
|
||||
needs: [benchmark]
|
||||
timeout-minutes: 180
|
||||
runs-on: tileops-metax-runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Verify nightly runner environment
|
||||
run: scripts/ci/verify_nightly_runner.sh
|
||||
shell: bash
|
||||
|
||||
- name: Install TileOPs (image-baked stack, --no-deps)
|
||||
run: bash scripts/ci/install_tileops.sh
|
||||
shell: bash
|
||||
|
||||
- name: Run full op tests
|
||||
id: op_tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PYTHONPATH="${GITHUB_WORKSPACE}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
echo "Runtime cache env:"
|
||||
echo "TILELANG_CACHE_DIR=${TILELANG_CACHE_DIR:-}"
|
||||
echo "TILELANG_TMP_DIR=${TILELANG_TMP_DIR:-}"
|
||||
echo "TRITON_CACHE_DIR=${TRITON_CACHE_DIR:-}"
|
||||
|
||||
set -o pipefail
|
||||
python3 -m pip install -q -c constraints.txt pytest-timeout
|
||||
python3 -m pytest tests/ -m "full or nightly" -v --tb=short --timeout=900 --timeout-method=thread --junit-xml=test_results.xml | tee tileops_op_test.log
|
||||
shell: bash
|
||||
|
||||
- name: Download benchmark artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: tileops_benchmark_${{ github.run_id }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Resolve previous perf history artifact
|
||||
id: perf_history_source
|
||||
if: ${{ always() }}
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const workflowId = "nightly.yml";
|
||||
const currentRunId = context.runId;
|
||||
const runs = await github.paginate(
|
||||
github.rest.actions.listWorkflowRuns,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: workflowId,
|
||||
branch: "dev",
|
||||
status: "completed",
|
||||
per_page: 100,
|
||||
},
|
||||
);
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.id === currentRunId || run.conclusion !== "success") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunArtifacts,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
},
|
||||
);
|
||||
|
||||
const artifact = artifacts.find(
|
||||
(candidate) =>
|
||||
!candidate.expired && candidate.name === "tileops_perf_history",
|
||||
);
|
||||
if (!artifact) {
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Using perf history artifact from run ${run.id} (${run.html_url})`,
|
||||
);
|
||||
core.setOutput("run_id", String(run.id));
|
||||
return;
|
||||
}
|
||||
|
||||
core.info("No previous perf history artifact found; nightly report will start a new history baseline.");
|
||||
|
||||
- name: Download previous perf history
|
||||
uses: actions/download-artifact@v4
|
||||
if: ${{ always() && steps.perf_history_source.outputs.run_id != '' }}
|
||||
with:
|
||||
name: tileops_perf_history
|
||||
path: .perf_history
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ steps.perf_history_source.outputs.run_id }}
|
||||
|
||||
- name: Generate nightly report
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PYTHONPATH="${GITHUB_WORKSPACE}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
|
||||
HISTORY_ARGS=()
|
||||
if [ -f .perf_history/perf_history.json ]; then
|
||||
HISTORY_ARGS=(--history .perf_history/perf_history.json)
|
||||
fi
|
||||
|
||||
python3 scripts/nightly_report.py \
|
||||
--test-xml test_results.xml \
|
||||
--bench-xml bench_results.xml \
|
||||
"${HISTORY_ARGS[@]}" \
|
||||
--output nightly_report.md \
|
||||
--history-out perf_history.json \
|
||||
|| echo "::warning::nightly_report.py failed"
|
||||
shell: bash
|
||||
|
||||
- name: Post nightly report to step summary
|
||||
if: ${{ always() && hashFiles('nightly_report.md') != '' }}
|
||||
run: cat nightly_report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
shell: bash
|
||||
|
||||
- name: Upload op test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
name: tileops_op_test_${{ github.run_id }}
|
||||
path: |
|
||||
test_results.xml
|
||||
tileops_op_test.log
|
||||
nightly_report.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload perf history
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ always() && hashFiles('perf_history.json') != '' }}
|
||||
with:
|
||||
name: tileops_perf_history
|
||||
path: perf_history.json
|
||||
overwrite: true
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail op test job if pytest failed
|
||||
if: ${{ always() && steps.op_tests.outcome == 'failure' }}
|
||||
run: exit 1
|
||||
shell: bash
|
||||
|
||||
# =========================================================================
|
||||
# Phase 2b — Publish benchmark data to the nightly-bench orphan branch
|
||||
# Consumed by tile-ai/TileOPs.github.io to render the docs Benchmarks page.
|
||||
# Runs on a github-hosted runner (no GPU needed) and uses the default token
|
||||
# to force-push the day's XML snapshot, mirroring the manifest-stats pattern.
|
||||
# =========================================================================
|
||||
publish-bench-data:
|
||||
if: ${{ always() && github.repository == 'tile-ai/TileOPs' && (github.event_name == 'schedule' || github.ref == 'refs/heads/main') }}
|
||||
needs: [op_test]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Download benchmark artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tileops_benchmark_${{ github.run_id }}
|
||||
path: _bench
|
||||
continue-on-error: true
|
||||
|
||||
- name: Download op test artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tileops_op_test_${{ github.run_id }}
|
||||
path: _optest
|
||||
continue-on-error: true
|
||||
|
||||
- name: Assemble publish payload
|
||||
id: assemble
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _publish
|
||||
ok=1
|
||||
if [ -f _bench/bench_results.xml ]; then
|
||||
cp _bench/bench_results.xml _publish/bench_results.xml
|
||||
else
|
||||
echo "::warning::bench_results.xml missing; skipping nightly-bench publish"
|
||||
ok=0
|
||||
fi
|
||||
if [ -f _optest/test_results.xml ]; then
|
||||
cp _optest/test_results.xml _publish/test_results.xml
|
||||
else
|
||||
echo "::warning::test_results.xml missing; page will render without test status"
|
||||
fi
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
run_date="$(date -u +%Y-%m-%d)"
|
||||
printf '{\n "commit": "%s",\n "date": "%s",\n "gpu": "NVIDIA H200",\n "run_id": "%s"\n}\n' \
|
||||
"${GITHUB_SHA}" "${run_date}" "${GITHUB_RUN_ID}" > _publish/meta.json
|
||||
fi
|
||||
echo "ok=${ok}" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
- name: Publish to nightly-bench branch
|
||||
if: ${{ steps.assemble.outputs.ok == '1' }}
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_branch: nightly-bench
|
||||
publish_dir: _publish
|
||||
force_orphan: true
|
||||
commit_message: "chore(bench): nightly benchmark data from ${{ github.sha }}"
|
||||
user_name: "github-actions[bot]"
|
||||
user_email: "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# =========================================================================
|
||||
# Phase 3 — Packaging and smoke test
|
||||
# =========================================================================
|
||||
packaging:
|
||||
if: ${{ always() && github.repository == 'tile-ai/TileOPs' && (github.event_name == 'schedule' || github.ref == 'refs/heads/main') }}
|
||||
needs: [op_test]
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: [self-hosted, tile-ops, nightly]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify nightly runner environment
|
||||
run: scripts/ci/verify_nightly_runner.sh
|
||||
shell: bash
|
||||
|
||||
- name: Install TileOPs (image-baked stack, --no-deps)
|
||||
run: bash scripts/ci/install_tileops.sh
|
||||
shell: bash
|
||||
|
||||
- name: Build and test wheel package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
exec > >(tee packaging.log) 2>&1
|
||||
|
||||
# Validate GPU frequency
|
||||
TARGET_CLOCK_MHZ=1500
|
||||
RETRIES=5
|
||||
for i in $(seq 1 "${RETRIES}"); do
|
||||
gpu_clock=$(nvidia-smi --query-gpu=clocks.current.graphics --format=csv,noheader,nounits | xargs)
|
||||
echo "GPU clock: ${gpu_clock} MHz (attempt ${i}/${RETRIES})"
|
||||
if [ "${gpu_clock}" = "${TARGET_CLOCK_MHZ}" ]; then break; fi
|
||||
if [ "${i}" -eq "${RETRIES}" ]; then
|
||||
echo "::error::GPU frequency validation failed. Expected ${TARGET_CLOCK_MHZ} MHz."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
python3 -m pip install -c constraints.txt build pytest pytest-timeout
|
||||
|
||||
echo "=== Build wheel package ==="
|
||||
python3 -m build --wheel
|
||||
echo "=== Build artifacts ==="
|
||||
ls -lh dist/*.whl
|
||||
|
||||
echo "=== Install built wheel ==="
|
||||
python3 -m pip install dist/*.whl
|
||||
|
||||
TMP_TEST_DIR="$(mktemp -d "${RUNNER_TEMP}/packaging-test.XXXXXX")"
|
||||
cp -r tests "${TMP_TEST_DIR}/tests"
|
||||
cp pyproject.toml "${TMP_TEST_DIR}/"
|
||||
cd "${TMP_TEST_DIR}"
|
||||
|
||||
echo "=== Runtime cache env ==="
|
||||
echo "TILELANG_CACHE_DIR=${TILELANG_CACHE_DIR:-}"
|
||||
echo "TILELANG_TMP_DIR=${TILELANG_TMP_DIR:-}"
|
||||
echo "TRITON_CACHE_DIR=${TRITON_CACHE_DIR:-}"
|
||||
|
||||
echo "=== Run pytest packaging ==="
|
||||
python3 -m pytest -q tests/ops -m "packaging" \
|
||||
--timeout=900 --timeout-method=thread \
|
||||
--junit-xml="${GITHUB_WORKSPACE}/packaging_smoke.xml"
|
||||
shell: bash
|
||||
|
||||
- name: Upload wheel artifact
|
||||
if: ${{ success() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tileops-wheel-${{ github.sha }}
|
||||
path: dist/*.whl
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload packaging logs
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packaging-logs-${{ github.sha }}
|
||||
path: |
|
||||
packaging.log
|
||||
packaging_smoke.xml
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Publish wheel to GitHub Release (tag only)
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*.whl
|
||||
fail_on_unmatched_files: true
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
name: Preflight Checks
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testbed]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [dev, testbed]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .claude/conventions
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Validate PR title format
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EVENT_NAME" != "pull_request" ]]; then
|
||||
echo "Not a pull request event; skipping PR title validation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
source .claude/conventions/types.sh
|
||||
if [[ ! "$PR_TITLE" =~ $COMMIT_MSG_PATTERN ]]; then
|
||||
echo "::error::PR title does not follow TileOPs format."
|
||||
echo "Expected: [Type] description or [Type][Scope] description"
|
||||
echo "Types: $COMMIT_PR_TYPES"
|
||||
echo "Got: $PR_TITLE"
|
||||
exit 1
|
||||
fi
|
||||
echo "PR title is valid: $PR_TITLE"
|
||||
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
manifest: ${{ steps.filter.outputs.manifest }}
|
||||
benchmark: ${{ steps.filter.outputs.benchmark }}
|
||||
steps:
|
||||
- name: Detect relevant changed paths
|
||||
id: filter
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
WORKFLOW_PATH: .github/workflows/preflight.yml
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Non-PR events (push to main, tags, manual dispatch) always run
|
||||
# both gated jobs — no base ref to diff against.
|
||||
if [[ "$EVENT_NAME" != "pull_request" ]]; then
|
||||
echo "manifest=true" >> "$GITHUB_OUTPUT"
|
||||
echo "benchmark=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fail-open: if the API call errors out or returns nothing, run
|
||||
# everything. The optimization must never reduce CI reliability.
|
||||
if ! FILES=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename' 2>&1); then
|
||||
echo "::warning::gh api failed; defaulting to run all gated jobs"
|
||||
echo "$FILES"
|
||||
echo "manifest=true" >> "$GITHUB_OUTPUT"
|
||||
echo "benchmark=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$FILES" ]]; then
|
||||
echo "::warning::no files returned; defaulting to run all gated jobs"
|
||||
echo "manifest=true" >> "$GITHUB_OUTPUT"
|
||||
echo "benchmark=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Changed files in PR #$PR_NUMBER:"
|
||||
echo "$FILES"
|
||||
|
||||
manifest=false
|
||||
benchmark=false
|
||||
while IFS= read -r f; do
|
||||
[[ -z "$f" ]] && continue
|
||||
case "$f" in
|
||||
tileops/manifest/*.yaml|tileops/manifest/__init__.py|scripts/validate_manifest.py|"$WORKFLOW_PATH")
|
||||
manifest=true
|
||||
;;
|
||||
esac
|
||||
# `benchmark-contract-tests` runs `pytest benchmarks/tests`, which
|
||||
# imports `workloads.workload_base`, `tileops.manifest`, and reads
|
||||
# the per-family files under `tileops/manifest/*.yaml` via
|
||||
# `load_workloads()` / `workloads_to_params()`. Any of those layers
|
||||
# can break the contract tests, so include them all.
|
||||
case "$f" in
|
||||
benchmarks/*|workloads/*|tileops/manifest/*.yaml|tileops/manifest/__init__.py|"$WORKFLOW_PATH")
|
||||
benchmark=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
echo "manifest=$manifest"
|
||||
echo "benchmark=$benchmark"
|
||||
echo "manifest=$manifest" >> "$GITHUB_OUTPUT"
|
||||
echo "benchmark=$benchmark" >> "$GITHUB_OUTPUT"
|
||||
|
||||
ci-gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.check.outputs.skip }}
|
||||
steps:
|
||||
- name: Check existing CI results for this SHA
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_ACTION: ${{ github.event.action || '' }}
|
||||
TARGET_BRANCH: ${{ github.base_ref || '' }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft || false }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$EVENT_NAME" != "pull_request" ]]; then
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$IS_DRAFT" == "true" ]]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
case "$TARGET_BRANCH" in
|
||||
dev|testbed) ;;
|
||||
*) echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0 ;;
|
||||
esac
|
||||
|
||||
if [[ "$EVENT_ACTION" != "ready_for_review" ]]; then
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# `benchmark-contract-tests` is intentionally skipped on PRs that
|
||||
# don't touch its inputs (see detect-changes). Keep that job
|
||||
# skippable, but still require the core preflight checks to be
|
||||
# successful so `ready_for_review` re-runs them and unblocks
|
||||
# gpu-smoke.
|
||||
CHECKS=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" --jq '
|
||||
[
|
||||
.check_runs
|
||||
| group_by(.name)
|
||||
| map(max_by(.id))[]
|
||||
| select(.name == "pre-commit" or .name == "gitleaks" or .name == "actionlint" or .name == "benchmark-contract-tests")
|
||||
| {name: .name, conclusion: .conclusion}
|
||||
]
|
||||
')
|
||||
ALL_OK=$(echo "$CHECKS" | jq '
|
||||
all(
|
||||
if .name == "benchmark-contract-tests"
|
||||
then (.conclusion == "success" or .conclusion == "skipped")
|
||||
else (.conclusion == "success")
|
||||
end
|
||||
)
|
||||
')
|
||||
COUNT=$(echo "$CHECKS" | jq 'length')
|
||||
if [[ "$ALL_OK" == "true" && "$COUNT" -ge 4 ]]; then
|
||||
echo "CI already passed for SHA $HEAD_SHA; skipping"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
pre-commit:
|
||||
needs: [validate-pr-title, ci-gate]
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && needs.ci-gate.outputs.skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
with:
|
||||
extra_args: --all-files --show-diff-on-failure
|
||||
|
||||
gitleaks:
|
||||
needs: [validate-pr-title, ci-gate]
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && needs.ci-gate.outputs.skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install gitleaks
|
||||
env:
|
||||
GITLEAKS_VERSION: "8.30.0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
install_dir="${RUNNER_TEMP}/bin"
|
||||
archive="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
||||
|
||||
mkdir -p "$install_dir"
|
||||
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${archive}" \
|
||||
-o "${RUNNER_TEMP}/${archive}"
|
||||
tar -xzf "${RUNNER_TEMP}/${archive}" -C "$install_dir" gitleaks
|
||||
echo "$install_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run gitleaks
|
||||
run: gitleaks dir . --redact --no-banner
|
||||
|
||||
validate-manifest:
|
||||
needs: [validate-pr-title, ci-gate, detect-changes]
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && needs.ci-gate.outputs.skip != 'true' && needs.detect-changes.outputs.manifest == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install TileOPs and validator dependencies
|
||||
run: pip install -e . -c constraints.txt
|
||||
|
||||
- name: Validate ops manifest
|
||||
run: python scripts/validate_manifest.py --levels schema,signature,shape,dtype,bench --strict
|
||||
|
||||
actionlint:
|
||||
needs: [validate-pr-title, ci-gate]
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && needs.ci-gate.outputs.skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: "1.7.11"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
install_dir="${RUNNER_TEMP}/bin"
|
||||
mkdir -p "$install_dir"
|
||||
tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
curl -sSfL \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${tarball}" \
|
||||
-o "${RUNNER_TEMP}/${tarball}"
|
||||
tar -xzf "${RUNNER_TEMP}/${tarball}" -C "$install_dir" actionlint
|
||||
echo "$install_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run actionlint
|
||||
run: actionlint -color
|
||||
|
||||
compile-contract-gate:
|
||||
# Always-on by design: no detect-changes path condition, so manifest-only
|
||||
# and test-only PRs both run the declaration/evidence equality test.
|
||||
needs: [validate-pr-title, ci-gate]
|
||||
if: ${{ github.repository == 'tile-ai/TileOPs' && needs.ci-gate.outputs.skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install TileOPs (with dev extras)
|
||||
run: pip install -e ".[dev]" -c constraints.txt
|
||||
|
||||
- name: Run manifest and compile-contract structural tests
|
||||
run: python -m pytest -q tests/test_validate_manifest.py
|
||||
|
||||
benchmark-contract-tests:
|
||||
# CPU-only contract tests for benchmark workload protocols.
|
||||
# Scoped to `benchmarks/tests/` so the GPU-bound `benchmarks/ops/`
|
||||
# suites (nightly-only) are NOT collected on PR CI.
|
||||
needs: [validate-pr-title, ci-gate, detect-changes]
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' && needs.ci-gate.outputs.skip != 'true' && needs.detect-changes.outputs.benchmark == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install TileOPs (with dev extras)
|
||||
run: pip install -e ".[dev]" -c constraints.txt
|
||||
|
||||
- name: Run benchmark contract tests
|
||||
run: python -m pytest -q benchmarks/tests
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
name: Runner Maintenance
|
||||
|
||||
# Heavy disk reclamation for the self-hosted runner. Runs once per day at
|
||||
# 18:00 UTC (02:00 Asia/Shanghai) so it lands in an off-peak window. Per-PR
|
||||
# `gpu-smoke` jobs only attempt the heavier reclaim path when disk is under
|
||||
# pressure, and still rate-limit the expensive cache-trim pass via a
|
||||
# cooldown; this scheduled job forces the full cleanup pass on days with
|
||||
# little PR traffic.
|
||||
#
|
||||
# GPU smoke and nightly share the persistent cache tree bind-mounted at
|
||||
# `/ci-cache/*` inside the runner container.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: runner-maintenance
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
reclaim-disk:
|
||||
if: ${{ github.repository == 'MetaX-MACA/TileOPs-Metax' }}
|
||||
# `nightly` (not `fork`) keeps maintenance on the resident shared-cache runners; the
|
||||
# destructive autotuner age-trim must never run against a fork pool's overlay cache.
|
||||
runs-on: tileops-metax-runner
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
# Invoke the action directly via owner/repo/path@ref so this workflow
|
||||
# does not depend on `actions/checkout` succeeding first — in the
|
||||
# disk-full scenarios this job is meant to remediate, a prior checkout
|
||||
# could itself fail for lack of space and prevent reclaim from ever
|
||||
# running. `@main` is trusted because this workflow only triggers on
|
||||
# schedule / workflow_dispatch; no fork-PR code path reaches it.
|
||||
- name: Reclaim runner disk
|
||||
uses: MetaX-MACA/TileOPs-Metax/.github/actions/reclaim-runner-disk@dev
|
||||
with:
|
||||
force-reclaim: "true"
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
name: stale-issues
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # Every Monday at 09:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close-stale-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close issues with no comments for 4 weeks
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CUTOFF=$(date -u -d "28 days ago" +%Y-%m-%dT%H:%M:%SZ)
|
||||
echo "Closing issues with no comment activity since: $CUTOFF"
|
||||
|
||||
OWNER="${GITHUB_REPOSITORY%/*}"
|
||||
REPO="${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
# Batch-fetch open issues with last comment time via GraphQL
|
||||
ISSUES_TO_CLOSE=()
|
||||
CURSOR=null
|
||||
|
||||
while :; do
|
||||
if [ "$CURSOR" = "null" ]; then
|
||||
# shellcheck disable=SC2016
|
||||
PAGE=$(gh api graphql -f owner="$OWNER" -f name="$REPO" -f query='
|
||||
query($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(first: 100, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
number
|
||||
createdAt
|
||||
labels(first: 100) { nodes { name } }
|
||||
comments(last: 1) { nodes { createdAt } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}')
|
||||
else
|
||||
# shellcheck disable=SC2016
|
||||
PAGE=$(gh api graphql -f owner="$OWNER" -f name="$REPO" -f cursor="$CURSOR" -f query='
|
||||
query($owner: String!, $name: String!, $cursor: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(first: 100, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}, after: $cursor) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
number
|
||||
createdAt
|
||||
labels(first: 100) { nodes { name } }
|
||||
comments(last: 1) { nodes { createdAt } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}')
|
||||
fi
|
||||
|
||||
HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.issues.pageInfo.hasNextPage')
|
||||
CURSOR=$(echo "$PAGE" | jq -r '.data.repository.issues.pageInfo.endCursor // "null"')
|
||||
|
||||
while IFS=" " read -r NUMBER LAST_ACTIVITY; do
|
||||
[ -z "$NUMBER" ] && continue
|
||||
ISSUES_TO_CLOSE+=("$NUMBER:$LAST_ACTIVITY")
|
||||
done < <(
|
||||
echo "$PAGE" | jq -r --arg cutoff "$CUTOFF" '
|
||||
.data.repository.issues.nodes[]
|
||||
| select([.labels.nodes[].name] | index("never-stale") | not)
|
||||
| .number as $n
|
||||
| (.comments.nodes[0].createdAt // .createdAt) as $last
|
||||
| select($last < $cutoff)
|
||||
| "\($n) \($last)"
|
||||
'
|
||||
)
|
||||
|
||||
[ "$HAS_NEXT" = "true" ] || break
|
||||
done
|
||||
|
||||
if [ "${#ISSUES_TO_CLOSE[@]}" -eq 0 ]; then
|
||||
echo "No stale issues found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for ENTRY in "${ISSUES_TO_CLOSE[@]}"; do
|
||||
NUMBER="${ENTRY%%:*}"
|
||||
LAST_ACTIVITY="${ENTRY#*:}"
|
||||
echo "Closing #$NUMBER (last activity: $LAST_ACTIVITY)"
|
||||
gh issue close "$NUMBER" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--comment "Closed automatically: no comment activity for 4 weeks. Reopen if still relevant."
|
||||
done
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
# Compiled Object files
|
||||
*.o
|
||||
*.obj
|
||||
*.pyc
|
||||
|
||||
# Editor files
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
.vs/
|
||||
.idea/*
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
wheelhouse/
|
||||
build_sdist/
|
||||
__pycache__
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
.tox/
|
||||
|
||||
# Git merge artifacts
|
||||
*.orig
|
||||
\#*
|
||||
\.#*
|
||||
|
||||
# macOS
|
||||
**/.DS_Store
|
||||
|
||||
# Logs and data
|
||||
*.log
|
||||
*.pkl_*
|
||||
|
||||
# Test / lint caches
|
||||
.pytest_cache
|
||||
.hypothesis
|
||||
.ruff_cache
|
||||
.cache/
|
||||
|
||||
# Output / profiling
|
||||
output/
|
||||
output*/
|
||||
profile_out/
|
||||
tmp/
|
||||
.tmp/
|
||||
debug/
|
||||
|
||||
# Local lock / scheduler state
|
||||
/.lock
|
||||
.claude/*.lock
|
||||
|
||||
# Exception: keep the debug folder for tests
|
||||
!testing/python/debug
|
||||
|
||||
# Secrets and credentials
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
credentials.json
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
|
||||
docs/plans/
|
||||
.humanize*
|
||||
.foundry/runs/
|
||||
.foundry/migrations/
|
||||
.foundry/plan/
|
||||
.foundry/config.local.json
|
||||
|
||||
CLAUDE.local.md
|
||||
.claude/worktrees/
|
||||
docs/superpowers/
|
||||
*.ncu-rep
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "3rdparty/tilelang-metax"]
|
||||
path = 3rdparty/tilelang-metax
|
||||
url = https://github.com/tile-ai/tilelang-metax.git
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
ci:
|
||||
autofix_prs: false
|
||||
autofix_commit_msg: "[Chore][Lint] pre-commit.ci auto fixes"
|
||||
autoupdate_commit_msg: "[CI] [pre-commit.ci] autoupdate"
|
||||
autoupdate_schedule: monthly
|
||||
default_stages: [pre-commit, pre-push, manual]
|
||||
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-symlinks
|
||||
- id: destroyed-symlinks
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-added-large-files
|
||||
- id: check-merge-conflict
|
||||
fail_fast: true
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: detect-private-key
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
- id: check-ast
|
||||
fail_fast: true
|
||||
- id: debug-statements
|
||||
- id: file-contents-sorter
|
||||
args: [--ignore-case]
|
||||
files: ^docs/spelling_wordlist\.txt$
|
||||
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.13
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args: [--fix, --exit-non-zero-on-fix, --config, pyproject.toml]
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies: [".[toml]"]
|
||||
exclude: |
|
||||
(?x)(
|
||||
^.+\.(cpp|hpp|cxx|cc|c|h|cu|cuh)$|
|
||||
^.+\.svg$|
|
||||
^.*\brequirements\b.*\.txt$
|
||||
)
|
||||
|
||||
- repo: https://github.com/executablebooks/mdformat
|
||||
rev: 0.7.22
|
||||
hooks:
|
||||
- id: mdformat
|
||||
additional_dependencies:
|
||||
- mdformat-gfm==0.4.1
|
||||
- mdformat-black==0.1.1
|
||||
- mdformat-myst==0.2.1
|
||||
- mdformat-tables==1.0.0
|
||||
- mdformat-toc==0.3.0
|
||||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 5675cadee5d9c37b9de29bc007e87cee11a345a5
|
||||
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -1,50 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
TileOPs is a high-performance LLM operator library built on TileLang. The goal is to provide efficient, modular, and maintainable AI workload implementations.
|
||||
|
||||
This project follows **design-first, spec-driven** development: design docs and `tileops/manifest/` are the authoritative spec; code conforms to the spec, not the other way around.
|
||||
|
||||
## Development Environment
|
||||
|
||||
Activate a virtual environment, then `make install` (deps + pre-commit hooks).
|
||||
|
||||
## Key References
|
||||
|
||||
### Design
|
||||
|
||||
- [architecture.md](docs/design/architecture.md) — system modules (M1-M8), data flow, agent production loop, directory structure
|
||||
- [ops-design.md](docs/design/ops-design.md) — Op interface execution guide (how to add a new op)
|
||||
- [ops-design-reference.md](docs/design/ops-design-reference.md) — Op interface detail reference (interface tables, codegen, naming, protocol)
|
||||
- [manifest.md](docs/design/manifest.md) — `tileops/manifest/` spec format (signature, workloads, roofline, source)
|
||||
- [roofline.md](docs/design/roofline.md) — `tileops/manifest/` `roofline` field spec: performance model, authoring, and per-consumer contracts (validator / benchmark / M5 / codegen)
|
||||
|
||||
### Process
|
||||
|
||||
- [trust-model.md](docs/design/trust-model.md) — trust boundaries (manifest → test → implementation → benchmark), workloads layer contract
|
||||
- [testing.md](docs/design/testing.md) — test/benchmark framework, core abstractions, tolerances, reporting rules
|
||||
- [tileops-skills.md](docs/tileops-skills.md) — developer decision guide: which repo-provided skill to use for which task
|
||||
|
||||
## Reading the ops manifest
|
||||
|
||||
The manifest lives at `tileops/manifest/`, one or more YAML files per op family — most families use a single file; large families may be sharded across multiple files. The `tileops.manifest` package merges them into a single `ops` dict at runtime.
|
||||
|
||||
- **Programmatic reads**: prefer `from tileops.manifest import load_manifest, load_workloads`. Never re-implement the merge.
|
||||
- **Structural inspection**: parse the relevant family file with `yaml.safe_load` and index `ops` by op name. Pick the file from the op's family field rather than scanning all of them.
|
||||
- **Edits**: edit the single family file that owns the op. Use a round-trip parser (`ruamel.yaml`) to preserve comments and key order. Op names must remain unique across files — duplicates raise at load time.
|
||||
- Reserve `Read`/`grep` for targeted line lookups inside one family file, not structural reading.
|
||||
|
||||
## Domain Rules (load on demand)
|
||||
|
||||
Read the relevant context file **before** modifying files in that domain. Do not load them if your task does not touch that domain.
|
||||
|
||||
| When you modify | Read first |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `tests/` | [.claude/domain-rules/testing-budget.md](.claude/domain-rules/testing-budget.md) |
|
||||
| `tileops/manifest/` | [.claude/domain-rules/manifest-spec.md](.claude/domain-rules/manifest-spec.md) |
|
||||
| `scripts/validate_manifest.py`, `tests/test_validate_manifest.py` | [.claude/domain-rules/manifest-validator.md](.claude/domain-rules/manifest-validator.md) |
|
||||
| `tileops/ops/`, `tileops/kernels/` | [.claude/domain-rules/ops-design.md](.claude/domain-rules/ops-design.md) |
|
||||
| `benchmarks/` | [.claude/domain-rules/benchmark.md](.claude/domain-rules/benchmark.md) |
|
||||
| `workloads/` | [docs/design/trust-model.md](docs/design/trust-model.md) |
|
||||
| `docs/design/` | [.claude/domain-rules/design-docs.md](.claude/domain-rules/design-docs.md) |
|
||||
24
LICENSE
24
LICENSE
|
|
@ -1,24 +0,0 @@
|
|||
Copyright (c) Tile-AI.
|
||||
Copyright (c) 2026 MetaX Integrated Circuits (Shanghai) Co., Ltd.
|
||||
|
||||
This project is modified from the TileOPs project (https://github.com/tile-ai/TileOPs)
|
||||
by MetaX Integrated Circuits (Shanghai) Co., Ltd. and is licensed under MIT
|
||||
license. The following is the original LICENSE from the TileOPs project.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in 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:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
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
|
||||
AUTHORS 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 IN THE
|
||||
SOFTWARE
|
||||
43
Makefile
43
Makefile
|
|
@ -1,43 +0,0 @@
|
|||
.PHONY: install install-bench lint test test-smoke test-full test-nightly bench clean help
|
||||
|
||||
install:
|
||||
pip install -e '.[dev]' -v
|
||||
pre-commit install
|
||||
|
||||
install-bench:
|
||||
PIP_NO_BUILD_ISOLATION=1 pip install -e '.[dev,bench]' 'git+https://github.com/fla-org/native-sparse-attention.git@bd67af59b90afa34b25f61d2922e612d10dba3bd' -v
|
||||
pre-commit install
|
||||
|
||||
lint:
|
||||
pre-commit run --all-files
|
||||
|
||||
test:
|
||||
python -m pytest -q tests
|
||||
|
||||
test-smoke:
|
||||
python -m pytest -q tests -m smoke
|
||||
|
||||
test-full:
|
||||
python -m pytest -q tests -m "smoke or full"
|
||||
|
||||
test-nightly:
|
||||
python -m pytest -q tests -m "smoke or full or nightly"
|
||||
|
||||
bench:
|
||||
python -m pytest benchmarks/
|
||||
|
||||
clean:
|
||||
rm -rf build/ dist/ tileops.egg-info
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " install Install dependencies and pre-commit hooks"
|
||||
@echo " install-bench Install with benchmark baseline libraries"
|
||||
@echo " lint Run linters on all files"
|
||||
@echo " test Run the test suite"
|
||||
@echo " test-smoke Run smoke-tier tests"
|
||||
@echo " test-full Run smoke + full-tier tests"
|
||||
@echo " test-nightly Run smoke + full + nightly-tier tests"
|
||||
@echo " bench Run benchmarks"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " help Show this help message"
|
||||
151
README.en.md
151
README.en.md
|
|
@ -1,151 +0,0 @@
|
|||
[简体中文](README.zh-CN.md) | **English**
|
||||
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/tile-ai/TileOPs/main/assets/logo.png" width="350"/>
|
||||
<h1>TileOPs</h1>
|
||||
<p><strong>Spec-driven GPU operator library for LLMs — designed for AI agents to build, evaluate, and optimize</strong></p>
|
||||
<p>Built on <a href="https://github.com/tile-ai/tilelang">TileLang</a></p>
|
||||
<!-- <p>
|
||||
<a href="https://pypi.org/project/tileops/"><img src="https://img.shields.io/badge/PyPI-tileops-1E90FF" alt="PyPI version" height="20"></a>
|
||||
</p> -->
|
||||
<p>
|
||||
<a href="https://github.com/tile-ai/TileOPs/tree/main/tileops/manifest"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftile-ai%2FTileOPs%2Fstats%2Fmanifest-implemented.json" alt="Spec coverage"></a>
|
||||
<a href="https://github.com/tile-ai/TileOPs/tree/main/benchmarks"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftile-ai%2FTileOPs%2Fstats%2Fmanifest-benchmark.json" alt="Bench coverage"></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#installation"><b>Installation</b></a> |
|
||||
<a href="#quick-start"><b>Quick Start</b></a> |
|
||||
<a href="#documentation"><b>Docs</b></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> **Status**: TileOPs is under active development. APIs may change.
|
||||
|
||||
## 2026 Summer Camp
|
||||
|
||||
Summer-camp participants should use the `summer-camp-2026` branch and follow the [Chinese migration guide](docs/summer-camp/README.md). The guide defines operator claiming, the two-PR Manifest/implementation workflow, MetaX GPU validation, benchmarking, Roofline evidence, and acceptance criteria; claimable projects are listed in the [operator migration inventory](docs/summer-camp/TileKernels-MACA-operator-migration-inventory.en.md).
|
||||
|
||||
We recommend renting online MetaX C500 compute through the [Gitee AI Compute Marketplace](https://ai.gitee.com/compute) and selecting the summer-camp image: `PyTorch-Agent / 2.8.0 / Python 3.12 / MACA 3.7.1.5`. See the [detailed English guide](docs/summer-camp/README.en.md) for setup and environment self-check requirements.
|
||||
|
||||
## Overview
|
||||
|
||||
TileOPs is a GPU operator library for LLM training and inference, built on [TileLang](https://github.com/tile-ai/tilelang). Beyond providing a growing collection of production-quality operators, TileOPs explores a **spec-driven development model** where AI agents can read declarative operator specifications, generate kernel implementations, and evaluate them against hardware-theoretical performance bounds — with minimal human scaffolding.
|
||||
|
||||
### Architecture
|
||||
|
||||
Every operator is split into two layers with a strict boundary:
|
||||
|
||||
- **Op** (L2) — stateless Python entry point. Handles validation, dtype casting, and memory layout. Compatible with CUDA-Graph and `torch.compile`.
|
||||
- **Kernel** (L1) — TileLang GPU implementation with hardware-specific optimizations. Upstream TileOPs kernels declare their support range in NVIDIA architecture terms (Ampere, Hopper); this repository adds `*_maca.py` implementations for some operators, dispatched at the Op layer via `is_maca()`. See [Operator availability on MetaX C500](docs/summer-camp/README.en.md#12-operator-availability-on-metax-c500).
|
||||
|
||||
This separation keeps user-facing behavior independent of GPU strategy, allowing agents and developers to modify either layer without side effects on the other.
|
||||
|
||||
### Key Properties
|
||||
|
||||
- **Spec-driven** — each operator is declared in a machine-readable manifest (`tileops/manifest/`) that specifies signatures, workloads, and roofline formulas, serving as the entry point for both agent code generation and automated validation
|
||||
- **Roofline-evaluated** — kernel performance is measured against Speed-of-Light hardware bounds, not relative baselines
|
||||
- **Auto-tuning** — built-in search over tile sizes, pipelines, and scheduling parameters
|
||||
- **Lightweight** — depends only on TileLang, PyTorch, and einops
|
||||
|
||||
## Installation
|
||||
|
||||
An MXMACA-capable MetaX GPU is required at runtime.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python >= 3.10
|
||||
- PyTorch >= 2.1 (MetaX build, e.g. `2.8.0+metax3.7.1.3`)
|
||||
- MetaX GPU: **C500**
|
||||
- [TileLang](https://github.com/tile-ai/tilelang): on MetaX, use the pre-built MACA version shipped in the container
|
||||
|
||||
> [!WARNING]
|
||||
> **Inside a MetaX container, do not run `make install`, `pip install tileops`, or any pip
|
||||
> command that resolves the tilelang dependency.**
|
||||
>
|
||||
> The container's TileLang is an in-place source build for MACA (e.g.
|
||||
> `/opt/tilelang-metax-v0.1.10`), not a pip package — `pip show tilelang` finds nothing.
|
||||
> pip therefore treats it as "not installed" and pulls the official **CUDA** wheel from the
|
||||
> index into site-packages, shadowing the MACA build so kernels compile for the wrong backend.
|
||||
>
|
||||
> For the same reason, do not use `python3 -m venv` without `--system-site-packages`: it cuts
|
||||
> off the MetaX PyTorch build and the `apache-tvm-ffi` that `libtilelang.so` is ABI-coupled to.
|
||||
>
|
||||
> Do not pass `-c constraints.txt` either. Those pins target the CUDA CI runner and would
|
||||
> downgrade `apache-tvm-ffi` below what the in-place build was compiled against. Such a
|
||||
> mismatch is invisible at `import` time and only fails when the first kernel compiles.
|
||||
|
||||
### MetaX: use the container's pre-built TileLang
|
||||
|
||||
Nothing needs to be installed. Set `PYTHONPATH` and you are ready:
|
||||
|
||||
```bash
|
||||
# Point at the container's pre-built MACA TileLang, plus this repository root
|
||||
export PYTHONPATH=/opt/tilelang-metax-v0.1.10:/path/to/TileOPs-Metax:$PYTHONPATH
|
||||
```
|
||||
|
||||
`tileops` imports without `pip install`; Manifest validation and tests run directly.
|
||||
|
||||
If you do need `tileops` registered in the environment (for example to run scripts from outside the repository), `--no-deps` is the only safe form:
|
||||
|
||||
```bash
|
||||
python -m pip install -e . --no-deps --no-build-isolation
|
||||
```
|
||||
|
||||
`--no-deps` is the essential part — it stops pip from resolving `tilelang`. The repository's CI uses exactly this form in [`scripts/ci/install_tileops.sh`](scripts/ci/install_tileops.sh).
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
# MetaX GPU status. If the output has a Sliced GPU section, the usable memory and compute
|
||||
# are the slice quota, not the whole-card values shown in the first section
|
||||
mx-smi
|
||||
python --version
|
||||
python -c "import torch; print(f'GPU available: {torch.cuda.is_available()}')"
|
||||
# PyTorch must be the MetaX build (version string contains 'metax')
|
||||
python -c "import torch; print(f'PyTorch {torch.__version__}')"
|
||||
# TileLang must come from the container's MACA build, not a pip-installed CUDA wheel.
|
||||
# The path should be under /opt/tilelang-metax-*; site-packages means it was overwritten
|
||||
python -c "import tilelang; print(tilelang.__version__); print(tilelang.__file__)"
|
||||
# The compilation backend must be maca, not cuda
|
||||
python -c "from tilelang.utils.target import determine_target; print(determine_target('auto'))"
|
||||
python -c "import einops; print('einops OK')"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
import torch
|
||||
from tileops.ops import GemmOp
|
||||
|
||||
M, N, K = 1024, 1024, 512
|
||||
|
||||
# GemmOp is input-inferred: m/n/k and dtype come from the forward inputs, so the
|
||||
# constructor only declares layout. trans_b=False means B is stored [K, N];
|
||||
# the default True corresponds to [N, K].
|
||||
gemm = GemmOp(trans_a=False, trans_b=False)
|
||||
|
||||
A = torch.randn(M, K, device="cuda", dtype=torch.float16)
|
||||
B = torch.randn(K, N, device="cuda", dtype=torch.float16)
|
||||
|
||||
C = gemm(A, B) # [M, N]
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Set `PYTHONPATH` first (see Installation above).
|
||||
>
|
||||
> On C500, `GemmOp` dispatches through `is_maca()` to
|
||||
> `tileops/kernels/gemm_maca.py`. Not every operator has a MACA implementation — before
|
||||
> picking an operator or a workload, read
|
||||
> [Operator availability on MetaX C500](docs/summer-camp/README.en.md#12-operator-availability-on-metax-c500).
|
||||
|
||||
## Documentation
|
||||
|
||||
Design docs and development guides are in [`docs/`](docs/). The full API reference and performance tables are published at [TileOPs.github.io](https://github.com/tile-ai/TileOPs.github.io).
|
||||
|
||||
## Contributing
|
||||
|
||||
See [docs/](docs/) for design docs. Branch and commit conventions are in [`.claude/conventions/types.sh`](.claude/conventions/types.sh).
|
||||
|
||||
## License
|
||||
|
||||
TileOPs is released under the [MIT License](LICENSE).
|
||||
63
README.md
63
README.md
|
|
@ -1,63 +0,0 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.png" width="300" alt="TileOPs-Metax Logo">
|
||||
|
||||
<h1>TileOPs-Metax</h1>
|
||||
|
||||
<p>
|
||||
<strong>面向大语言模型、由规范驱动的 GPU 算子库</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>简体中文</strong> |
|
||||
<a href="README.en.md">English</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> **项目状态**:TileOPs-Metax 正在积极开发中,API 可能发生变化。
|
||||
|
||||
## 首届开源英才夏令营
|
||||
|
||||
夏令营学员应使用 `summer-camp-2026` 分支,并遵循[算子迁移指南](docs/summer-camp/README.zh-CN.md)。
|
||||
|
||||
### 任务说明
|
||||
|
||||
学员以小组为单位,从筹备组发布的[候选算子清单](docs/summer-camp/TileKernels-MACA-待迁移算子盘点.md)中选择一个主算子。候选算子主要来源于 [`MetaX-MACA/TileKernels-Metax`](https://github.com/MetaX-MACA/TileKernels-Metax) 的 `dev` 分支,并已初步评估 MetaX C500 适配或性能优化空间。学员需要按照 TileOPs 的接口和工程规范完成算子泛化、迁移或优化,而不是简单复制源实现:
|
||||
|
||||
1. 认领算子并提交 Manifest;
|
||||
2. 完成 Op、TileLang Kernel、正确性及边界测试;
|
||||
3. 在真实 MetaX C500 上完成正确性验证、Benchmark、Profiler 和 Roofline 分析;
|
||||
4. 提交可 Review 的实现 PR,并参加成果答辩。
|
||||
|
||||
完整时间安排、提交要求和验收标准见[算子迁移指南](docs/summer-camp/README.zh-CN.md)。
|
||||
|
||||
**可认领范围以筹备组发布的候选算子清单为准,源仓库中的算子不会默认全部开放认领。每组应优先选择一个中等或较高难度的主算子;在保证主算子完整交付并经助教确认后,最多可追加两个算子。每个算子须单独认领并分别提交 PR,不得提前占用尚未开始的算子。**
|
||||
|
||||
### 结营证书
|
||||
|
||||
按要求完成任务并参加成果答辩的学员,可获得本届开源英才夏令营结营证书。
|
||||
|
||||
### 优秀项目评选
|
||||
|
||||
答辩结束后,评委将结合小组答辩、PR、代码与测试质量、MetaX C500 验证证据、性能分析及开源复用价值,综合考虑算子难度、实际完成情况、优化效果和工程贡献,评出以下三个奖项:
|
||||
|
||||
- **最佳性能优化(1 组)**:性能分析准确,优化方案合理,在代表性工作负载上取得稳定且可信的性能提升。
|
||||
- **最佳工程实践(1 组)**:算子交付完整,代码结构清晰,测试覆盖充分,PR 和复现材料规范。
|
||||
- **最佳开源贡献(1 组)**:成果具有较高的复用、扩展或推广价值,能够帮助后续算子开发、硬件适配和社区协作。
|
||||
|
||||
原则上每组最多获得一个主奖。评奖不以绝对加速比作为唯一依据,评委将结合算子基础难度、工作量、完成质量和证据可信度综合判断。
|
||||
|
||||
## 项目简介
|
||||
|
||||
TileOPs-Metax 是一个基于 [TileLang](https://github.com/tile-ai/tilelang)、面向大语言模型训练和推理的 GPU 算子库。项目采用规范驱动的开发模式,帮助开发者和 AI Agent 构建、评估和优化高性能算子。
|
||||
|
||||
### 主要特性
|
||||
|
||||
- **规范驱动**:使用机器可读的 Manifest 定义算子签名和工作负载。
|
||||
- **性能评估**:结合 Benchmark 与 Roofline 分析评估 Kernel 性能。
|
||||
- **分层设计**:用户接口与硬件优化策略相互独立。
|
||||
|
||||
## 详细文档
|
||||
|
||||
- [查看完整中文说明](README.zh-CN.md)
|
||||
- [View the full English README](README.en.md)
|
||||
- [查看夏令营算子迁移指南](docs/summer-camp/README.zh-CN.md)
|
||||
150
README.zh-CN.md
150
README.zh-CN.md
|
|
@ -1,150 +0,0 @@
|
|||
**简体中文** | [English](README.en.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/tile-ai/TileOPs/main/assets/logo.png" width="350"/>
|
||||
<h1>TileOPs</h1>
|
||||
<p><strong>面向大语言模型、由规范驱动的 GPU 算子库——帮助 AI Agent 构建、评估和优化算子</strong></p>
|
||||
<p>基于 <a href="https://github.com/tile-ai/tilelang">TileLang</a> 构建</p>
|
||||
<!-- <p>
|
||||
<a href="https://pypi.org/project/tileops/"><img src="https://img.shields.io/badge/PyPI-tileops-1E90FF" alt="PyPI version" height="20"></a>
|
||||
</p> -->
|
||||
<p>
|
||||
<a href="https://github.com/tile-ai/TileOPs/tree/main/tileops/manifest"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftile-ai%2FTileOPs%2Fstats%2Fmanifest-implemented.json" alt="Spec coverage"></a>
|
||||
<a href="https://github.com/tile-ai/TileOPs/tree/main/benchmarks"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Ftile-ai%2FTileOPs%2Fstats%2Fmanifest-benchmark.json" alt="Bench coverage"></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#安装"><b>安装</b></a> |
|
||||
<a href="#快速开始"><b>快速开始</b></a> |
|
||||
<a href="#文档"><b>文档</b></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> **项目状态**:TileOPs 正在积极开发中,API 可能发生变化。
|
||||
|
||||
## 首届开源英才夏令营
|
||||
|
||||
夏令营学员应使用 `summer-camp-2026` 分支,并遵循[算子迁移指南](docs/summer-camp/README.md)。该指南规定了算子认领、Manifest/实现双 PR 流程、MetaX GPU 验证、Benchmark、Roofline 证据和验收要求;可认领课题见[候选算子清单](docs/summer-camp/TileKernels-MACA-待迁移算子盘点.md)。
|
||||
|
||||
推荐通过[模力方舟算力市场](https://ai.gitee.com/compute)租用在线 MetaX C500 算力,并选择夏令营专属镜像:`PyTorch-Agent / 2.8.0 / Python 3.12 / MACA 3.7.1.5`。具体使用方法和环境自检要求见[详细中文指南](docs/summer-camp/README.zh-CN.md)。
|
||||
|
||||
## 概述
|
||||
|
||||
TileOPs 是一个基于 [TileLang](https://github.com/tile-ai/tilelang)、面向大语言模型训练和推理的 GPU 算子库。除了持续提供可用于生产的算子,TileOPs 还探索一种**规范驱动的开发模式**:AI Agent 可以读取声明式算子规范、生成 Kernel 实现,并依据硬件理论性能上限进行评估,同时尽量减少人工脚手架。
|
||||
|
||||
### 架构
|
||||
|
||||
每个算子都严格分为两个层次:
|
||||
|
||||
- **Op**(L2)——无状态 Python 入口,负责参数校验、dtype 转换和内存布局,并兼容 CUDA Graph 与 `torch.compile`。
|
||||
- **Kernel**(L1)——TileLang GPU 实现,包含针对具体硬件的优化策略。上游 TileOPs 的 Kernel 按 NVIDIA 架构(Ampere、Hopper)声明支持范围;本仓库为部分算子提供 `*_maca.py` 专用实现,由 Op 层通过 `is_maca()` 分派。参见 [MetaX C500 上的算子可用范围](docs/summer-camp/README.zh-CN.md#12-metax-c500-上的算子可用范围)。
|
||||
|
||||
这种分层使面向用户的行为与 GPU 策略相互独立,AI Agent 和开发者可以修改其中一层,而不对另一层产生意外影响。
|
||||
|
||||
### 主要特性
|
||||
|
||||
- **规范驱动**——每个算子都由机器可读的 Manifest(`tileops/manifest/`)声明,
|
||||
其中定义签名、工作负载和 Roofline 公式,同时作为 AI Agent 生成代码与自动验证的入口。
|
||||
- **Roofline 评估**——Kernel 性能依据硬件 Speed-of-Light 理论上限评估,而不是只与相对基线比较。
|
||||
- **自动调优**——内置对 tile 大小、流水线和调度参数的搜索。
|
||||
- **轻量依赖**——仅依赖 TileLang、PyTorch 和 einops。
|
||||
|
||||
## 安装
|
||||
|
||||
运行时需要支持 MXMACA 的 MetaX GPU。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Python >= 3.10
|
||||
- PyTorch >= 2.1(MetaX 定制版,如 `2.8.0+metax3.7.1.3`)
|
||||
- MetaX GPU:**C500**
|
||||
- [TileLang](https://github.com/tile-ai/tilelang):MetaX 环境使用容器内预编译的 MACA 版本
|
||||
|
||||
> [!WARNING]
|
||||
> **MetaX 容器内不要执行 `make install`、`pip install tileops`,或任何会解析 tilelang 依赖的 pip 命令。**
|
||||
>
|
||||
> 容器里的 TileLang 是源码就地编译的 MACA 版本(例如 `/opt/tilelang-metax-v0.1.10`),
|
||||
> 不是 pip 包(`pip show tilelang` 查不到)。因此 pip 会认为它「未安装」,从镜像源拉取
|
||||
> 官方 **CUDA** 构建的 wheel 装进 site-packages,遮蔽 MACA 编译产物,导致 Kernel 编译走错后端。
|
||||
>
|
||||
> 同理,不要使用不带 `--system-site-packages` 的 `python3 -m venv`,否则会切断 MetaX 定制版
|
||||
> PyTorch 和与 `libtilelang.so` ABI 耦合的 `apache-tvm-ffi`。
|
||||
>
|
||||
> 也不要在 pip 命令中带 `-c constraints.txt`:该文件的钉版面向 CUDA CI 环境,会降级
|
||||
> `apache-tvm-ffi`,与容器内编译产物 ABI 不匹配。这类不匹配在 `import` 阶段看不出来,
|
||||
> 要到第一次编译 Kernel 时才会失败。
|
||||
|
||||
### MetaX 环境:使用容器内预编译的 TileLang
|
||||
|
||||
不需要安装任何东西。设置 `PYTHONPATH` 后即可直接使用:
|
||||
|
||||
```bash
|
||||
# 指向容器内预编译的 MACA 版 TileLang,以及本仓库根目录
|
||||
export PYTHONPATH=/opt/tilelang-metax-v0.1.10:/path/to/TileOPs-Metax:$PYTHONPATH
|
||||
```
|
||||
|
||||
`tileops` 无需 `pip install` 即可导入,Manifest 校验和测试都能直接运行。
|
||||
|
||||
如果确实需要把 `tileops` 注册进环境(例如想在仓库外的目录运行脚本),只能用 `--no-deps`:
|
||||
|
||||
```bash
|
||||
python -m pip install -e . --no-deps --no-build-isolation
|
||||
```
|
||||
|
||||
`--no-deps` 是关键,它让 pip 不去解析 `tilelang` 依赖。仓库 CI 使用的 [`scripts/ci/install_tileops.sh`](scripts/ci/install_tileops.sh) 就是这个写法。
|
||||
|
||||
验证安装:
|
||||
|
||||
```bash
|
||||
# 检查沐曦 GPU 状态。注意:若输出含 Sliced GPU 段落,实际可用显存和算力是切片配额,
|
||||
# 不是第一段显示的整卡值
|
||||
mx-smi
|
||||
# 检查 Python 版本
|
||||
python --version
|
||||
# 检查 PyTorch 是否能识别 GPU
|
||||
python -c "import torch; print(f'GPU available: {torch.cuda.is_available()}'); print(f'GPU count: {torch.cuda.device_count()}')"
|
||||
# 检查 PyTorch 是 MetaX 定制版(版本号应含 metax)
|
||||
python -c "import torch; print(f'PyTorch {torch.__version__}')"
|
||||
# 检查 TileLang 来自容器内的 MACA 构建,而不是 pip 装的官方 CUDA 版。
|
||||
# 路径应指向 /opt/tilelang-metax-*;若指向 site-packages,说明已被覆盖,需要恢复
|
||||
python -c "import tilelang; print(tilelang.__version__); print(tilelang.__file__)"
|
||||
# 检查编译后端是 maca 而不是 cuda
|
||||
python -c "from tilelang.utils.target import determine_target; print(determine_target('auto'))"
|
||||
python -c "import einops; print('einops OK')"
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```python
|
||||
import torch
|
||||
from tileops.ops import GemmOp
|
||||
|
||||
M, N, K = 1024, 1024, 512
|
||||
|
||||
# GemmOp 是输入推断的:m/n/k 和 dtype 由 forward 的输入决定,构造时只声明布局。
|
||||
# trans_b=False 表示 B 按 [K, N] 存储;默认值 True 对应 [N, K]。
|
||||
gemm = GemmOp(trans_a=False, trans_b=False)
|
||||
|
||||
A = torch.randn(M, K, device="cuda", dtype=torch.float16)
|
||||
B = torch.randn(K, N, device="cuda", dtype=torch.float16)
|
||||
|
||||
C = gemm(A, B) # [M, N]
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 运行前需要先设置 `PYTHONPATH`(见上文安装小节)。
|
||||
>
|
||||
> 在 C500 上,`GemmOp` 通过 `is_maca()` 分派到 `tileops/kernels/gemm_maca.py`。
|
||||
> 并非所有算子都有 MACA 实现,选择算子和工作负载前请先阅读
|
||||
> [MetaX C500 上的算子可用范围](docs/summer-camp/README.zh-CN.md#12-metax-c500-上的算子可用范围)。
|
||||
|
||||
## 文档
|
||||
|
||||
设计文档和开发指南位于 [`docs/`](docs/) 目录。完整 API 参考和性能表发布在 [TileOPs.github.io](https://github.com/tile-ai/TileOPs.github.io)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
设计文档请参阅 [`docs/`](docs/)。分支与提交规范位于[`.claude/conventions/types.sh`](.claude/conventions/types.sh)。
|
||||
|
||||
## 许可证
|
||||
|
||||
TileOPs 使用 [MIT License](LICENSE) 发布。
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
# Third-Party Notices
|
||||
|
||||
This repository includes or adapts code from the following MIT-licensed
|
||||
projects. The top-level `LICENSE` contains the MIT license text used for this
|
||||
repository distribution; the original copyright notices below are retained in
|
||||
the relevant source files.
|
||||
|
||||
## Qwen FlashQLA
|
||||
|
||||
Files under `tileops/kernels/gated_deltanet/gdn_prefill/` implement a TileOps
|
||||
version of the CP-split Gated DeltaNet prefill schedule. The schedule-level
|
||||
reference for the h-state / corrected-segment-start part of this implementation
|
||||
comes from Qwen FlashQLA:
|
||||
|
||||
- `__init__.py`
|
||||
- `cp_fwd.py`
|
||||
- `fused_fwd.py`
|
||||
- `prepare_h.py`
|
||||
- `tilelang_compat.py`
|
||||
- `utils.py`
|
||||
|
||||
Original notice:
|
||||
|
||||
```text
|
||||
Copyright (c) 2026 The Qwen team, Alibaba Group.
|
||||
Licensed under the MIT License.
|
||||
```
|
||||
|
||||
The TileOps versions are not direct wrappers around the FlashQLA kernels. They
|
||||
adapt the CP-split scheduling idea into the TileOps operator API, BTHD dispatch,
|
||||
TileLang compatibility layer, benchmarking, tests, and local replay/output
|
||||
implementation. The utility file keeps the upstream copyright notice present in
|
||||
the FlashQLA utility source.
|
||||
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 169 KiB |
|
|
@ -1,6 +0,0 @@
|
|||
from .benchmark_base import BenchmarkBase, BenchmarkReport
|
||||
|
||||
__all__ = [
|
||||
"BenchmarkBase",
|
||||
"BenchmarkReport",
|
||||
]
|
||||
|
|
@ -1,720 +0,0 @@
|
|||
import contextlib
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Generic,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.autograd.profiler import DeviceType
|
||||
|
||||
from tileops.manifest import (
|
||||
WORKLOAD_RESERVED_KEYS,
|
||||
load_manifest,
|
||||
load_workloads,
|
||||
single_input_workload_contract,
|
||||
)
|
||||
|
||||
|
||||
def _workload_contract(op_name: str) -> tuple[str, frozenset[str]]:
|
||||
"""Resolve the shared workload contract for an op known to exist."""
|
||||
sig = load_manifest()[op_name].get("signature") or {}
|
||||
contract = single_input_workload_contract(sig)
|
||||
if contract is None:
|
||||
raise KeyError(
|
||||
f"workloads_to_params({op_name!r}) needs exactly one manifest "
|
||||
"tensor input; multi-input ops use their own bench files."
|
||||
)
|
||||
return contract
|
||||
|
||||
|
||||
# Benchmark capability protocols
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ShapeDtypeWorkload(Protocol):
|
||||
"""Structural type for workloads that carry shape and dtype metadata.
|
||||
|
||||
Any object with ``shape`` and ``dtype`` satisfies this protocol.
|
||||
Used by helpers that only need tensor metadata, not input generation
|
||||
capability.
|
||||
"""
|
||||
|
||||
shape: tuple[int, ...]
|
||||
dtype: torch.dtype
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InputGeneratingWorkload(Protocol):
|
||||
"""Structural type for workloads that can generate benchmark inputs."""
|
||||
|
||||
def gen_inputs(self) -> tuple[Any, ...]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BenchmarkWorkload(ShapeDtypeWorkload, InputGeneratingWorkload, Protocol):
|
||||
"""Full benchmark workload: shape/dtype metadata + input generation.
|
||||
|
||||
This is the standard contract for benchmark workloads that need both
|
||||
roofline metadata extraction and input tensor generation.
|
||||
Workloads satisfy this protocol when they define ``shape`` and ``dtype``
|
||||
metadata in addition to implementing ``gen_inputs()``.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
W = TypeVar("W")
|
||||
|
||||
|
||||
_logger = logging.getLogger("tileops.bench")
|
||||
|
||||
# Thread-local storage for conftest hook to pick up per-test bench results.
|
||||
# A single test function may call record() multiple times (tileops + baseline).
|
||||
_bench_results = threading.local()
|
||||
|
||||
# Latest bench_kernel measurement metadata; deviations from the default
|
||||
# protocol are surfaced in results by BenchmarkBase._build_result.
|
||||
_bench_meta = threading.local()
|
||||
|
||||
|
||||
class _CuptiProjectionError(Exception):
|
||||
"""CUPTI trace lacked a projected annotation window for every repeat."""
|
||||
|
||||
|
||||
# Name of the ``record_function`` annotation wrapping the timed call. Kineto
|
||||
# projects this scope onto the device timeline. The L2-flush ``cache.zero_()``
|
||||
# is synchronized to completion before the window opens (see ``bench_kernel``),
|
||||
# so its device event cannot fall inside a window regardless of how the
|
||||
# projection behaves; kernels the timed call launches do.
|
||||
_KERNEL_REGION = "tileops_bench_kernel"
|
||||
|
||||
|
||||
def _sum_kernel_time_us(kineto_results):
|
||||
"""Sum device time of the kernels the timed call launched.
|
||||
|
||||
Sums only kernels inside a :data:`_KERNEL_REGION` annotation window, so the
|
||||
L2-flush fill is excluded and the kernel under test is counted regardless of
|
||||
its name. A call launching several kernels contributes all of them.
|
||||
|
||||
Iterates the C++ Kineto events directly to bypass ``key_averages()``, which
|
||||
is ~16x slower (~130ms of Python parsing/tree-building) for large traces.
|
||||
|
||||
Returns:
|
||||
``(total_us, n_regions)``: summed kernel time in microseconds and the
|
||||
number of annotation windows. The caller checks ``n_regions ==
|
||||
n_repeat`` to confirm the scope projected on every iteration.
|
||||
"""
|
||||
import bisect
|
||||
|
||||
windows: list[tuple[int, int]] = []
|
||||
kernels: list[tuple[int, int]] = [] # (start_ns, duration_ns)
|
||||
for evt in kineto_results.events():
|
||||
if evt.device_type() != DeviceType.CUDA:
|
||||
continue
|
||||
if evt.is_user_annotation():
|
||||
if evt.name() == _KERNEL_REGION:
|
||||
windows.append((evt.start_ns(), evt.end_ns()))
|
||||
continue
|
||||
kernels.append((evt.start_ns(), evt.duration_ns()))
|
||||
|
||||
windows.sort()
|
||||
starts = [w[0] for w in windows]
|
||||
ends = [w[1] for w in windows]
|
||||
total_us = 0.0
|
||||
for start_ns, dur_ns in kernels:
|
||||
# Count only kernels that fall inside a timed-call window; everything
|
||||
# outside (notably the L2-flush fill) is excluded.
|
||||
idx = bisect.bisect_right(starts, start_ns) - 1
|
||||
if idx >= 0 and start_ns < ends[idx]:
|
||||
total_us += dur_ns / 1000.0
|
||||
return total_us, len(windows)
|
||||
|
||||
|
||||
# L2 cache flush buffer (sized to actual L2, allocated lazily)
|
||||
|
||||
_l2_flush_cache: Optional[torch.Tensor] = None
|
||||
|
||||
|
||||
def _get_l2_flush_cache() -> torch.Tensor:
|
||||
global _l2_flush_cache
|
||||
if _l2_flush_cache is None:
|
||||
l2_bytes = torch.cuda.get_device_properties(0).L2_cache_size
|
||||
if l2_bytes <= 0:
|
||||
_logger.warning(
|
||||
"L2 cache size query returned %d; flushing a 256 MB buffer "
|
||||
"instead", l2_bytes,
|
||||
)
|
||||
l2_bytes = int(256e6)
|
||||
_l2_flush_cache = torch.empty(l2_bytes // 4, dtype=torch.int, device="cuda")
|
||||
return _l2_flush_cache
|
||||
|
||||
|
||||
def _native_output_suppressor():
|
||||
"""Return an fd-level output suppressor that is safe under pytest capture.
|
||||
|
||||
tilelang's ``suppress_stdout_stderr`` dup2's ``/dev/null`` over
|
||||
``sys.stdout.fileno()``; under pytest fd capture that fileno is the
|
||||
capture tmpfile and the redirect corrupts it (``EBADF`` on later reads).
|
||||
Suppress only when stdout/stderr are the process fds 1/2.
|
||||
"""
|
||||
try:
|
||||
native = sys.stdout.fileno() == 1 and sys.stderr.fileno() == 2
|
||||
except (AttributeError, OSError, ValueError):
|
||||
# Streams without a real descriptor (io.StringIO, capsys) or with
|
||||
# fileno() unsupported: fd-level suppression is impossible.
|
||||
native = False
|
||||
if not native:
|
||||
return contextlib.nullcontext()
|
||||
from tilelang.profiler.bench import suppress_stdout_stderr
|
||||
return suppress_stdout_stderr()
|
||||
|
||||
|
||||
# NVIDIA SOL-ExecBench–style benchmark
|
||||
|
||||
def bench_kernel(
|
||||
fn: Callable,
|
||||
args: tuple[Any, ...] = (),
|
||||
n_warmup: int = 10,
|
||||
n_repeat: int = 50,
|
||||
n_trials: int = 3,
|
||||
) -> float:
|
||||
"""Benchmark a GPU kernel with pure kernel timing via CUPTI.
|
||||
|
||||
Protocol (adapted from NVIDIA SOL-ExecBench, arxiv.org/abs/2603.19173):
|
||||
1. Lock GPU clocks externally (nvidia-smi).
|
||||
2. Run *n_warmup* un-timed iterations with L2 flush.
|
||||
3. For each of *n_trials* trials, profile *n_repeat* iterations
|
||||
under CUPTI to get pure kernel execution time (no launch overhead).
|
||||
L2 is flushed before every iteration. Input tensors are cloned
|
||||
each iteration so the kernel always sees fresh addresses.
|
||||
4. Report the median trial mean (robust to outlier trials).
|
||||
|
||||
Uses CUPTI via torch.profiler for accurate kernel-only timing, with
|
||||
direct Kineto C++ event iteration to avoid Python parsing overhead.
|
||||
Falls back to CUDA events if CUPTI is unavailable.
|
||||
|
||||
Args:
|
||||
fn: Callable to benchmark. If *args* is provided, called as
|
||||
``fn(*cloned_args)``; otherwise called as ``fn()``.
|
||||
args: Tensor arguments to clone each iteration. Non-tensor
|
||||
values are passed through unchanged.
|
||||
n_warmup: Warmup iterations (default 10).
|
||||
n_repeat: Timed iterations per trial (default 50).
|
||||
n_trials: Independent trials (default 3).
|
||||
|
||||
Returns:
|
||||
Kernel latency in **milliseconds**.
|
||||
"""
|
||||
if not isinstance(args, tuple):
|
||||
raise TypeError(
|
||||
f"bench_kernel expects a tuple of args, got {type(args).__name__}. "
|
||||
"Check that gen_inputs() returns a tuple."
|
||||
)
|
||||
|
||||
cache = _get_l2_flush_cache()
|
||||
has_args = len(args) > 0
|
||||
|
||||
# Pre-clone a small pool of input tensors so the kernel sees different
|
||||
# addresses across iterations. Skip cloning if total tensor memory
|
||||
# exceeds 1 GB to avoid OOM on large workloads.
|
||||
_N_CLONES = 3
|
||||
_MAX_CLONE_BYTES = 1 << 30 # 1 GB
|
||||
if has_args:
|
||||
tensor_mask = tuple(isinstance(a, torch.Tensor) for a in args)
|
||||
total_bytes = sum(a.nelement() * a.element_size()
|
||||
for a, m in zip(args, tensor_mask, strict=True) if m)
|
||||
if total_bytes * _N_CLONES <= _MAX_CLONE_BYTES:
|
||||
arg_pool = [
|
||||
tuple(a.clone() if m else a for a, m in zip(args, tensor_mask, strict=True))
|
||||
for _ in range(_N_CLONES)
|
||||
]
|
||||
def _run(i):
|
||||
return fn(*arg_pool[i % _N_CLONES])
|
||||
else:
|
||||
_logger.warning(
|
||||
"bench_kernel: inputs total %.2f GiB; skipping per-iteration "
|
||||
"cloning (kernel sees identical addresses)",
|
||||
total_bytes / (1 << 30),
|
||||
)
|
||||
arg_pool = None
|
||||
def _run(i):
|
||||
return fn(*args)
|
||||
else:
|
||||
arg_pool = None
|
||||
def _run(i):
|
||||
return fn()
|
||||
_bench_meta.inputs_cloned = arg_pool is not None or not has_args
|
||||
|
||||
# Warmup (no profiling)
|
||||
for i in range(n_warmup):
|
||||
cache.zero_()
|
||||
_run(i)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
# One plain profiler context per trial; torch.profiler.schedule is avoided
|
||||
# because queued launches leak across its warmup/active boundary.
|
||||
# Kineto's window projection may include a flush merely enqueued before
|
||||
# the window, so the flush is drained (sync) before the timed call and
|
||||
# the call is drained before the next flush; the syncs add host-side
|
||||
# latency only.
|
||||
trial_means: list[float] = []
|
||||
try:
|
||||
with _native_output_suppressor():
|
||||
for _ in range(n_trials):
|
||||
with torch.profiler.profile(
|
||||
# CPU activity is required for Kineto to project the
|
||||
# annotation window; it never adds device time.
|
||||
activities=[
|
||||
torch.profiler.ProfilerActivity.CPU,
|
||||
torch.profiler.ProfilerActivity.CUDA,
|
||||
],
|
||||
) as profiler:
|
||||
for i in range(n_repeat):
|
||||
cache.zero_()
|
||||
torch.cuda.synchronize()
|
||||
with torch.profiler.record_function(_KERNEL_REGION):
|
||||
_run(i)
|
||||
torch.cuda.synchronize()
|
||||
total_us, n_regions = _sum_kernel_time_us(profiler.profiler.kineto_results)
|
||||
# Untrustworthy trace → CUDA-events fallback; genuine CUDA
|
||||
# errors and OOM propagate.
|
||||
if n_regions != n_repeat:
|
||||
raise _CuptiProjectionError(
|
||||
f"{n_regions}/{n_repeat} annotation windows projected"
|
||||
)
|
||||
trial_means.append((total_us / n_repeat) * 1e-3)
|
||||
_bench_meta.timing = "cupti"
|
||||
except _CuptiProjectionError as exc:
|
||||
_logger.warning(
|
||||
"CUPTI projection failed (%s); falling back to CUDA-events "
|
||||
"timing, which includes launch overhead", exc,
|
||||
)
|
||||
trial_means = []
|
||||
|
||||
# Fallback to CUDA events if CUPTI failed
|
||||
if not trial_means:
|
||||
_bench_meta.timing = "cuda-events"
|
||||
for _ in range(n_trials):
|
||||
start_events = [torch.cuda.Event(enable_timing=True) for _ in range(n_repeat)]
|
||||
end_events = [torch.cuda.Event(enable_timing=True) for _ in range(n_repeat)]
|
||||
for i in range(n_repeat):
|
||||
cache.zero_()
|
||||
start_events[i].record()
|
||||
_run(i)
|
||||
end_events[i].record()
|
||||
torch.cuda.synchronize()
|
||||
times = [s.elapsed_time(e) for s, e in zip(start_events, end_events, strict=True)]
|
||||
trial_means.append(sum(times) / len(times))
|
||||
|
||||
# Free the arg pool and release cached GPU memory to prevent
|
||||
# accumulation across hundreds of benchmark calls.
|
||||
if arg_pool is not None:
|
||||
del arg_pool
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
trial_means.sort()
|
||||
return trial_means[len(trial_means) // 2]
|
||||
|
||||
|
||||
def _get_env_metadata() -> list[str]:
|
||||
"""Collect GPU model, driver version, CUDA version, and torch version."""
|
||||
lines = []
|
||||
lines.append(f"- **Torch version**: {torch.__version__}")
|
||||
lines.append(f"- **CUDA version (torch)**: {torch.version.cuda or 'N/A'}")
|
||||
|
||||
if torch.cuda.is_available():
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
lines.append(f"- **GPU model**: {gpu_name}")
|
||||
else:
|
||||
lines.append("- **GPU model**: N/A (no CUDA device)")
|
||||
|
||||
# Try to get NVIDIA driver version and clocks from nvidia-smi.
|
||||
gpu_query_fields = [
|
||||
"driver_version",
|
||||
"clocks.current.sm",
|
||||
"clocks.current.memory",
|
||||
"clocks.applications.graphics",
|
||||
"clocks.applications.memory",
|
||||
]
|
||||
gpu_query_values = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
f"--query-gpu={','.join(gpu_query_fields)}",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
gpu_query_values = [
|
||||
part.strip() for part in result.stdout.splitlines()[0].split(",")
|
||||
]
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
driver = gpu_query_values[0] if len(gpu_query_values) == len(gpu_query_fields) else "N/A"
|
||||
lines.append(f"- **Driver version**: {driver}")
|
||||
|
||||
if len(gpu_query_values) == len(gpu_query_fields):
|
||||
sm_clock, mem_clock, app_sm_clock, app_mem_clock = gpu_query_values[1:]
|
||||
lines.append(
|
||||
"- **GPU clocks**: "
|
||||
f"SM current {sm_clock} MHz, memory current {mem_clock} MHz, "
|
||||
f"application SM {app_sm_clock} MHz, "
|
||||
f"application memory {app_mem_clock} MHz"
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class BenchmarkBase(Generic[W], ABC):
|
||||
"""Abstract base class for op benchmarking.
|
||||
|
||||
Generic over workload type so subclasses can declare the exact
|
||||
capability they need. ``WorkloadBase`` remains the typical in-repo
|
||||
implementation, but the public contract is the type parameter.
|
||||
|
||||
Subclass must implement calculate_flops() and calculate_memory().
|
||||
"""
|
||||
|
||||
def __init__(self, workload: W):
|
||||
self.workload = workload
|
||||
|
||||
@abstractmethod
|
||||
def calculate_flops(self) -> Optional[float]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def calculate_memory(self) -> Optional[float]:
|
||||
raise NotImplementedError
|
||||
|
||||
def profile(self,
|
||||
functor: Any,
|
||||
*inputs: Any) -> dict:
|
||||
"""Profile a callable and return structured results.
|
||||
|
||||
Uses the NVIDIA SOL-ExecBench protocol: CUPTI kernel timing,
|
||||
10 warmup, 50 repeats × 3 trials, L2 flush sized to actual
|
||||
cache, input tensors cloned each iteration.
|
||||
"""
|
||||
with torch.no_grad():
|
||||
latency = bench_kernel(functor, args=inputs)
|
||||
return self._build_result(latency)
|
||||
|
||||
def profile_autograd(self, functor: Any) -> dict:
|
||||
"""Profile a callable that requires autograd (e.g. fwd+bwd).
|
||||
|
||||
Same as profile() but without torch.no_grad(), so the callable
|
||||
can build autograd graphs and call .backward() internally.
|
||||
The functor must be a zero-arg closure that captures its inputs.
|
||||
"""
|
||||
latency = bench_kernel(functor)
|
||||
return self._build_result(latency)
|
||||
|
||||
def _build_result(self, latency: float) -> dict:
|
||||
result = {"latency_ms": latency}
|
||||
# Deviations from the default protocol must be visible in reports.
|
||||
timing = getattr(_bench_meta, "timing", None)
|
||||
if timing is not None and timing != "cupti":
|
||||
result["timing"] = timing
|
||||
if getattr(_bench_meta, "inputs_cloned", True) is False:
|
||||
result["inputs_cloned"] = False
|
||||
flops = self.calculate_flops()
|
||||
if flops is not None:
|
||||
result["tflops"] = flops / latency * 1e-9
|
||||
memory = self.calculate_memory()
|
||||
if memory is not None:
|
||||
result["bandwidth_tbs"] = memory / latency * 1e-9
|
||||
return result
|
||||
|
||||
|
||||
# Manifest-driven benchmark helpers
|
||||
|
||||
|
||||
def _workload_extra_params(w: dict, shape_key: str) -> dict[str, Any]:
|
||||
"""Return op-call params on a workload entry, stripping reserved keys."""
|
||||
reserved = WORKLOAD_RESERVED_KEYS | {shape_key}
|
||||
return {
|
||||
k: v
|
||||
for k, v in w.items()
|
||||
if isinstance(k, str) and k not in reserved and not k.startswith("__")
|
||||
}
|
||||
|
||||
|
||||
def workloads_to_params(op_name: str, include_extra: bool = False) -> list:
|
||||
"""Convert manifest workload dicts for *op_name* to pytest params.
|
||||
|
||||
Each entry becomes ``pytest.param(shape, dtype, id=...)``; with
|
||||
``include_extra=True`` a third element carries the op-call params
|
||||
declared on the workload entry (e.g. ``{"dim": 0}``).
|
||||
"""
|
||||
workloads = load_workloads(op_name) # canonical not-found error
|
||||
shape_key, allowed = _workload_contract(op_name)
|
||||
params = []
|
||||
for w in workloads:
|
||||
if shape_key not in w:
|
||||
raise KeyError(
|
||||
f"workload {w.get('label', w)!r} of {op_name!r} is missing "
|
||||
f"{shape_key!r} (derived from the signature's input name)."
|
||||
)
|
||||
unknown = sorted(
|
||||
repr(k) for k in w
|
||||
if not isinstance(k, str) or (k not in allowed and not k.startswith("__"))
|
||||
)
|
||||
if unknown:
|
||||
raise KeyError(
|
||||
f"workload {w.get('label', w)!r} of {op_name!r} has unknown "
|
||||
f"keys {unknown}; allowed: {sorted(allowed)}."
|
||||
)
|
||||
shape = tuple(w[shape_key])
|
||||
label = w.get("label", "x".join(str(s) for s in shape))
|
||||
extra = _workload_extra_params(w, shape_key) if include_extra else {}
|
||||
for dtype_str in w["dtypes"]:
|
||||
dtype = getattr(torch, dtype_str)
|
||||
# Copy ``extra`` per parametrization so mutation in one test case
|
||||
# cannot leak into later cases sharing the workload entry.
|
||||
param_args = (
|
||||
(shape, dtype, dict(extra))
|
||||
if include_extra
|
||||
else (shape, dtype)
|
||||
)
|
||||
params.append(pytest.param(*param_args, id=f"{label}-{dtype_str}"))
|
||||
return params
|
||||
|
||||
|
||||
class ManifestBenchmark(BenchmarkBase[ShapeDtypeWorkload]):
|
||||
"""Generic benchmark that reads FLOP/memory counts from an Op instance.
|
||||
|
||||
Accepts an op name, an instantiated Op, and any workload satisfying
|
||||
:class:`ShapeDtypeWorkload`. The op must implement ``eval_roofline()``.
|
||||
Dynamic-shape ops may bind roofline variables during ``forward()``, so
|
||||
this helper calls ``op.eval_roofline()`` only while building a result
|
||||
after profiling has executed the op.
|
||||
|
||||
Usage::
|
||||
|
||||
op = SumFwdOp(dtype=dtype, dim=0)
|
||||
bm = ManifestBenchmark("SumFwdOp", op, workload)
|
||||
result = bm.profile(op, *inputs)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
op_name: str,
|
||||
op: Any,
|
||||
workload: ShapeDtypeWorkload,
|
||||
):
|
||||
super().__init__(workload)
|
||||
self._op_name = op_name
|
||||
self._op = op
|
||||
self._roofline_cache: Optional[tuple[float, float]] = None
|
||||
|
||||
def _get_roofline(self) -> tuple[float, float]:
|
||||
if self._roofline_cache is None:
|
||||
flops, mem_bytes = self._op.eval_roofline()
|
||||
self._roofline_cache = (float(flops), float(mem_bytes))
|
||||
return self._roofline_cache
|
||||
|
||||
def calculate_flops(self) -> Optional[float]:
|
||||
return self._get_roofline()[0]
|
||||
|
||||
def calculate_memory(self) -> Optional[float]:
|
||||
return self._get_roofline()[1]
|
||||
|
||||
|
||||
def _extract_op_config(op: object) -> Optional[dict]:
|
||||
"""Return the kernel config for an Op instance, or None if unavailable.
|
||||
|
||||
Handles the three Op patterns currently used in tileops:
|
||||
|
||||
1. **Eager-init** (e.g. ``GemmOp``): ``op.kernel`` is a Kernel
|
||||
instance set in ``__init__``.
|
||||
2. **Lazy with dummy kernel** (e.g. ``FFTC2COp``): ``op.kernel`` is a
|
||||
default Kernel and ``op._kernel_cache`` may hold others.
|
||||
3. **Pure lazy cache** (e.g. ``_SoftmaxBaseOp`` and the spec-conformant
|
||||
reduction ops): ``op._kernel_cache`` is the only source; ``op.kernel``
|
||||
is unset.
|
||||
|
||||
A direct ``op.config`` attribute (legacy / explicit override) takes
|
||||
precedence over kernel introspection.
|
||||
"""
|
||||
op_config = getattr(op, "config", None)
|
||||
if op_config:
|
||||
return op_config
|
||||
|
||||
kernel = getattr(op, "kernel", None)
|
||||
op_config = getattr(kernel, "config", None) if kernel is not None else None
|
||||
if op_config:
|
||||
return op_config
|
||||
|
||||
# Pure lazy-cache pattern: pick any cached kernel's config. All cached
|
||||
# kernels for a given op share dtype/op_kind, so taking the first is
|
||||
# sufficient for the benchmark report (which records one entry per call).
|
||||
cache = getattr(op, "_kernel_cache", None)
|
||||
if cache:
|
||||
try:
|
||||
first_kernel = next(iter(cache.values()))
|
||||
except StopIteration:
|
||||
first_kernel = None
|
||||
if first_kernel is not None:
|
||||
op_config = getattr(first_kernel, "config", None)
|
||||
if op_config:
|
||||
return op_config
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class BenchmarkReport:
|
||||
"""Collects benchmark results and dumps a markdown report.
|
||||
|
||||
All methods are static — use as BenchmarkReport.record(...).
|
||||
Call clear() at session start, dump() at session end.
|
||||
"""
|
||||
_records: dict = {}
|
||||
|
||||
@staticmethod
|
||||
def record(op_or_name, params: dict, result: dict, tag: str = "tileops") -> None:
|
||||
"""Record a benchmark result.
|
||||
|
||||
Args:
|
||||
op_or_name: Op instance or benchmark group name string.
|
||||
If an Op instance, class name and module are extracted automatically.
|
||||
params: Parameter dict (typically from locals())
|
||||
result: Dict with latency_ms, tflops, bandwidth_tbs
|
||||
tag: Label to distinguish implementations (e.g. "tileops", "FA3", "fla")
|
||||
"""
|
||||
if isinstance(op_or_name, str):
|
||||
name = op_or_name
|
||||
op_module = None
|
||||
op_config = None
|
||||
else:
|
||||
name = op_or_name.__class__.__name__
|
||||
op_module = op_or_name.__class__.__module__
|
||||
op_config = _extract_op_config(op_or_name)
|
||||
|
||||
# Filter params to only include serializable benchmark parameters.
|
||||
# Tuples of primitives (e.g. ``shape=(4096, 4096)``) are preserved
|
||||
# verbatim so the profile log carries the original input geometry
|
||||
# rather than a flattened element count.
|
||||
def _is_serializable(v: Any) -> bool:
|
||||
if isinstance(v, (int, float, bool, str, torch.dtype)):
|
||||
return True
|
||||
if isinstance(v, tuple):
|
||||
return all(_is_serializable(x) for x in v)
|
||||
return False
|
||||
|
||||
filtered_params = {
|
||||
k: v for k, v in params.items()
|
||||
if k not in ("test", "bm", "op", "inputs", "result", "result_bl",
|
||||
"baseline_fn", "tune")
|
||||
and not k.startswith("_")
|
||||
and _is_serializable(v)
|
||||
}
|
||||
record_entry = {
|
||||
"params": filtered_params,
|
||||
"result": result,
|
||||
"tag": tag,
|
||||
}
|
||||
if op_config:
|
||||
record_entry["config"] = op_config
|
||||
BenchmarkReport._records.setdefault(name, []).append(record_entry)
|
||||
|
||||
# Accumulate in thread-local for conftest hook.
|
||||
if not hasattr(_bench_results, "entries"):
|
||||
_bench_results.entries = []
|
||||
entry = {"tag": tag, "op": name, **result}
|
||||
if op_module:
|
||||
entry["op_module"] = op_module
|
||||
_bench_results.entries.append(entry)
|
||||
|
||||
_logger.info("op=%s module=%s tag=%s latency_ms=%.4f tflops=%.2f",
|
||||
name, op_module or "N/A", tag,
|
||||
result.get("latency_ms", 0),
|
||||
result.get("tflops", 0))
|
||||
|
||||
@staticmethod
|
||||
def dump(path: str) -> None:
|
||||
"""Write all collected results to a markdown-formatted log file."""
|
||||
if not BenchmarkReport._records:
|
||||
return
|
||||
|
||||
lines = [
|
||||
"# TileOPs Benchmark Report",
|
||||
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
"",
|
||||
"## Environment",
|
||||
"",
|
||||
]
|
||||
lines.extend(_get_env_metadata())
|
||||
lines.append("")
|
||||
|
||||
default_result_keys = ["latency_ms", "tflops", "bandwidth_tbs"]
|
||||
|
||||
for name, entries in BenchmarkReport._records.items():
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
lines.append(f"## {name}")
|
||||
lines.append("")
|
||||
|
||||
# Group by tag
|
||||
tag_entries = {}
|
||||
for entry in entries:
|
||||
tag_entries.setdefault(entry["tag"], []).append(entry)
|
||||
result_keys = list(default_result_keys)
|
||||
for entry in entries:
|
||||
for key in entry["result"]:
|
||||
if key not in result_keys:
|
||||
result_keys.append(key)
|
||||
|
||||
for tag, tag_group in tag_entries.items():
|
||||
lines.append(f"### {tag}")
|
||||
lines.append("")
|
||||
|
||||
param_keys = list(tag_group[0]["params"].keys())
|
||||
has_config = any("config" in e for e in tag_group)
|
||||
header_parts = param_keys + result_keys
|
||||
if has_config:
|
||||
header_parts.append("config")
|
||||
lines.append("| " + " | ".join(header_parts) + " |")
|
||||
lines.append("| " + " | ".join(["---"] * len(header_parts)) + " |")
|
||||
|
||||
for entry in tag_group:
|
||||
row = [str(entry["params"].get(k, "")) for k in param_keys]
|
||||
for rk in result_keys:
|
||||
val = entry["result"].get(rk)
|
||||
if val is None:
|
||||
row.append("N/A")
|
||||
elif isinstance(val, (int, float)) and not isinstance(val, bool):
|
||||
row.append(f"{val:.4f}")
|
||||
else:
|
||||
row.append(str(val))
|
||||
if has_config:
|
||||
cfg = entry.get("config")
|
||||
row.append(str(cfg) if cfg else "")
|
||||
lines.append("| " + " | ".join(row) + " |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
with open(path, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"Benchmark report saved to {path}")
|
||||
|
||||
@staticmethod
|
||||
def clear() -> None:
|
||||
"""Clear all collected records."""
|
||||
BenchmarkReport._records.clear()
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import gc
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from benchmarks.benchmark_base import BenchmarkReport, _bench_results
|
||||
|
||||
# Skip NSA benchmarks until the underlying op failures are resolved.
|
||||
collect_ignore_glob = [
|
||||
"ops/attention/bench_deepseek_nsa*.py",
|
||||
]
|
||||
|
||||
def _normalized_benchmark_nodeid(item: pytest.Item) -> str:
|
||||
nodeid = item.nodeid
|
||||
if nodeid.startswith("benchmarks/"):
|
||||
return nodeid
|
||||
if nodeid.startswith("ops/"):
|
||||
return f"benchmarks/{nodeid}"
|
||||
return nodeid
|
||||
|
||||
|
||||
def _is_fp8_e4m3_benchmark(item: pytest.Item) -> bool:
|
||||
callspec = getattr(item, "callspec", None)
|
||||
if callspec is None:
|
||||
return False
|
||||
return callspec.params.get("dtype") == torch.float8_e4m3fn
|
||||
|
||||
|
||||
def _release_cuda_cache_after_case() -> None:
|
||||
"""Drop per-case Python references and cached CUDA blocks between benchmarks."""
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup() -> None:
|
||||
torch.manual_seed(1235)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(1235)
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
BenchmarkReport.clear()
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
BenchmarkReport.dump("profile_run.log")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
|
||||
fp8_e4m3_skip = pytest.mark.skip(
|
||||
reason=(
|
||||
"Skipped under tilelang 0.1.9: fp8 e4m3 benchmark fails due to "
|
||||
"lowering regression; re-enable when fp8 e4m3 benchmarks run "
|
||||
"cleanly against current tilelang."
|
||||
)
|
||||
)
|
||||
|
||||
for item in items:
|
||||
nodeid = _normalized_benchmark_nodeid(item)
|
||||
path = nodeid.split("::", 1)[0]
|
||||
|
||||
if (
|
||||
path == "benchmarks/ops/bench_elementwise_fp8.py"
|
||||
and _is_fp8_e4m3_benchmark(item)
|
||||
):
|
||||
item.add_marker(fp8_e4m3_skip)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_call(item):
|
||||
"""After bench test execution, attach perf data to the item as properties."""
|
||||
_bench_results.entries = []
|
||||
try:
|
||||
yield
|
||||
entries = getattr(_bench_results, "entries", [])
|
||||
if not entries:
|
||||
return
|
||||
|
||||
# Separate tileops entry (tag starts with "tileops") from baselines.
|
||||
tileops_entry = None
|
||||
baseline_entries = []
|
||||
for e in entries:
|
||||
if e["tag"].startswith("tileops"):
|
||||
if tileops_entry is None:
|
||||
tileops_entry = e
|
||||
else:
|
||||
baseline_entries.append(e)
|
||||
|
||||
if tileops_entry:
|
||||
item.user_properties.append(("op", tileops_entry["op"]))
|
||||
if "op_module" in tileops_entry:
|
||||
item.user_properties.append(("op_module", tileops_entry["op_module"]))
|
||||
tag = tileops_entry["tag"]
|
||||
if tag != "tileops" and tag.startswith("tileops_"):
|
||||
item.user_properties.append(("tileops_variant", tag[len("tileops_"):]))
|
||||
item.user_properties.append(("tileops_latency_ms",
|
||||
f"{tileops_entry.get('latency_ms', 0):.4f}"))
|
||||
tflops = tileops_entry.get("tflops")
|
||||
if tflops is not None:
|
||||
item.user_properties.append(("tileops_tflops", f"{tflops:.2f}"))
|
||||
bw = tileops_entry.get("bandwidth_tbs")
|
||||
if bw is not None:
|
||||
item.user_properties.append(("tileops_bandwidth_tbs", f"{bw:.2f}"))
|
||||
|
||||
# Write all baselines into JUnit XML properties.
|
||||
# The first baseline uses the legacy unprefixed names (baseline_tag, etc.)
|
||||
# for backward compatibility. Additional baselines use "{tag}_latency_ms",
|
||||
# "{tag}_tflops", "{tag}_ratio" so the report can display multiple columns.
|
||||
for idx, be in enumerate(baseline_entries):
|
||||
tag = be["tag"]
|
||||
bl_latency = be.get("latency_ms", 0)
|
||||
bl_tflops = be.get("tflops")
|
||||
|
||||
if idx == 0:
|
||||
# Legacy unprefixed keys — consumed by existing nightly_report.py
|
||||
item.user_properties.append(("baseline_tag", tag))
|
||||
item.user_properties.append(("baseline_latency_ms", f"{bl_latency:.4f}"))
|
||||
if bl_tflops is not None:
|
||||
item.user_properties.append(("baseline_tflops", f"{bl_tflops:.2f}"))
|
||||
if tileops_entry:
|
||||
tl = tileops_entry.get("latency_ms", 0)
|
||||
if tl > 0 and bl_latency > 0:
|
||||
item.user_properties.append(("baseline_ratio",
|
||||
f"{bl_latency / tl:.4f}"))
|
||||
|
||||
# Tag-prefixed keys — always written for every baseline
|
||||
item.user_properties.append((f"{tag}_latency_ms", f"{bl_latency:.4f}"))
|
||||
if bl_tflops is not None:
|
||||
item.user_properties.append((f"{tag}_tflops", f"{bl_tflops:.2f}"))
|
||||
if tileops_entry:
|
||||
tl = tileops_entry.get("latency_ms", 0)
|
||||
if tl > 0 and bl_latency > 0:
|
||||
item.user_properties.append((f"{tag}_ratio", f"{bl_latency / tl:.4f}"))
|
||||
finally:
|
||||
_bench_results.entries = []
|
||||
_release_cuda_cache_after_case()
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
# Hardware Microbenchmarks
|
||||
|
||||
GPU hardware characterization benchmarks that produce calibration factors for `tileops/perf/profiles/`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- NVIDIA GPU with CUDA toolkit (`nvcc` in PATH)
|
||||
- TileOPs installed (`pip install -e .` from project root)
|
||||
- Root/sudo access for clock locking (recommended)
|
||||
|
||||
## HBM Bandwidth
|
||||
|
||||
Measures peak HBM bandwidth using vectorized CUDA kernels (float4 load/store) with cudaEvent timing. The calibration factor is derived from the **STREAM Triad** kernel (`a[i] = b[i] + s*c[i]`, 2 reads + 1 write), the industry-standard pattern for roofline bandwidth calibration.
|
||||
|
||||
Triad's 2:1 read:write ratio is closer to real compute kernels than pure copy (1:1), which suffers worst-case HBM bus turnaround overhead. Copy, read-only, and write-only results are included as reference measurements.
|
||||
|
||||
### Lock GPU clocks (recommended)
|
||||
|
||||
GPU boost clocks fluctuate during benchmarks. Lock memory and SM clocks to their maximum for stable, reproducible results:
|
||||
|
||||
```bash
|
||||
# Lock clocks (requires root/sudo)
|
||||
sudo nvidia-smi -lgc $(nvidia-smi --query-gpu=clocks.max.sm --format=csv,noheader,nounits)
|
||||
sudo nvidia-smi -lmc $(nvidia-smi --query-gpu=clocks.max.mem --format=csv,noheader,nounits)
|
||||
```
|
||||
|
||||
After benchmarking, reset to default:
|
||||
|
||||
```bash
|
||||
sudo nvidia-smi -rgc
|
||||
sudo nvidia-smi -rmc
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
Run from project root:
|
||||
|
||||
```bash
|
||||
python benchmarks/hardware/memory/hbm_bandwidth.py --profile h200 --arch sm_90
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ----------- | ------- | ----------------------------------------------------------------------- |
|
||||
| `--profile` | `h200` | GPU profile name (reads theoretical peak from `tileops/perf/profiles/`) |
|
||||
| `--arch` | `sm_90` | CUDA compute capability for nvcc |
|
||||
| `--size-mb` | `2048` | Working set size in MB |
|
||||
|
||||
### Output
|
||||
|
||||
```
|
||||
Measured peak (triad vec4): 4070.44 GB/s
|
||||
Theoretical: 4800.0 GB/s
|
||||
Calibration: 0.8480
|
||||
|
||||
Update tileops/perf/profiles/h200.yaml:
|
||||
hbm.calibration: 0.8480
|
||||
```
|
||||
|
||||
### Methodology
|
||||
|
||||
- **Calibration kernel:** STREAM Triad `a[i] = b[i] + s*c[i]` (2 reads + 1 write, `float4` vectorized)
|
||||
- **Reference kernels:** Copy (1:1 read:write), Read-only, Write-only
|
||||
- **Timing:** `cudaEvent` (GPU-side, no host overhead)
|
||||
- **Warmup:** 100 iterations per config (ensures boost clocks stabilize)
|
||||
- **Measurement:** 200 iterations × 5 runs, report best and median
|
||||
- **Working set:** 2 GB default (>> L2 cache, ensures HBM is measured)
|
||||
- **Calibration source:** best Triad bandwidth across block size sweep (128/256/512)
|
||||
|
||||
## Adding a new GPU profile
|
||||
|
||||
1. Create `tileops/perf/profiles/<gpu>.yaml` with theoretical specs from the datasheet
|
||||
1. Lock GPU clocks (see above)
|
||||
1. Run `python benchmarks/hardware/memory/hbm_bandwidth.py --profile <gpu> --arch <sm_XX>`
|
||||
1. Update `<gpu>.yaml` with the measured calibration factor
|
||||
1. Reset GPU clocks
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""Hardware characterization microbenchmark suite.
|
||||
|
||||
Measures GPU memory bandwidth, compute throughput, and system overhead
|
||||
to produce calibration factors for GPU profiles (tileops/perf/profiles/).
|
||||
"""
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Memory subsystem microbenchmarks."""
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
"""HBM Bandwidth Benchmark — Python wrapper for hbm_saturation.cu.
|
||||
|
||||
Compiles and runs the CUDA microbenchmark, parses output, and prints
|
||||
the calibration factor for tileops/perf/profiles/.
|
||||
|
||||
Calibration is derived from the STREAM Triad kernel (a = b + s*c, 2 reads +
|
||||
1 write). Triad is the industry standard for roofline bandwidth calibration:
|
||||
|
||||
McCalpin, J.D., 1995. "Memory Bandwidth and Machine Balance in Current
|
||||
High Performance Computers." IEEE TCCA Newsletter.
|
||||
https://www.cs.virginia.edu/stream/
|
||||
|
||||
Williams, S., Waterman, A. & Patterson, D., 2009. "Roofline: An Insightful
|
||||
Visual Performance Model for Multicore Architectures." CACM 52(4).
|
||||
|
||||
Usage:
|
||||
python benchmarks/hardware/memory/hbm_bandwidth.py [--profile h200] [--size-mb 2048]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from tileops.perf import load_profile
|
||||
|
||||
_CU_SRC = Path(__file__).parent / "hbm_saturation.cu"
|
||||
|
||||
|
||||
def _compile(cu_path, binary_path, arch="sm_90"):
|
||||
"""Compile the CUDA source. Raises on failure."""
|
||||
cmd = [
|
||||
"nvcc", "-O3", f"-arch={arch}",
|
||||
"-Wno-deprecated-gpu-targets",
|
||||
"-o", str(binary_path), str(cu_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"nvcc compilation failed:\n{result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _run(binary_path, size_mb, theo_peak_gbs):
|
||||
"""Run the benchmark binary and return stdout lines."""
|
||||
cmd = [str(binary_path), str(size_mb), str(theo_peak_gbs)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||||
if result.returncode != 0:
|
||||
print(f"Benchmark failed:\n{result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout.strip().splitlines()
|
||||
|
||||
|
||||
def _parse_triad_peak(lines):
|
||||
"""Extract the best Triad bandwidth (GB/s) from CSV output.
|
||||
|
||||
Only considers lines starting with 'triad,' — STREAM Triad (2 reads +
|
||||
1 write) is the standard calibration kernel for roofline analysis.
|
||||
Its 2:1 read:write ratio is closer to real compute kernels than pure
|
||||
copy (1:1), which suffers worst-case HBM bus turnaround overhead.
|
||||
"""
|
||||
best_gbs = 0.0
|
||||
for line in lines:
|
||||
if not line.startswith("triad,"):
|
||||
continue
|
||||
parts = line.split(",")
|
||||
if len(parts) >= 6:
|
||||
try:
|
||||
gbs = float(parts[5]) # best_gbs column
|
||||
best_gbs = max(best_gbs, gbs)
|
||||
except ValueError:
|
||||
continue
|
||||
return best_gbs
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="HBM bandwidth microbenchmark")
|
||||
parser.add_argument("--profile", default="h200", help="GPU profile name")
|
||||
parser.add_argument("--size-mb", type=int, default=2048, help="Working set size in MB")
|
||||
parser.add_argument("--arch", default="sm_90", help="CUDA architecture")
|
||||
args = parser.parse_args()
|
||||
|
||||
profile = load_profile(args.profile)
|
||||
theo_peak_gbs = profile["hbm"]["theoretical"] / 1e9
|
||||
|
||||
print(f"Profile: {args.profile}")
|
||||
print(f"Theoretical HBM BW: {theo_peak_gbs:.1f} GB/s")
|
||||
print(f"Working set: {args.size_mb} MB")
|
||||
print()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
binary = Path(tmpdir) / "hbm_saturation"
|
||||
|
||||
print("Compiling hbm_saturation.cu ...")
|
||||
_compile(_CU_SRC, binary, arch=args.arch)
|
||||
|
||||
print("Running benchmark (5 runs x 200 reps, this may take a few minutes) ...\n")
|
||||
lines = _run(binary, args.size_mb, theo_peak_gbs)
|
||||
|
||||
# Print raw output
|
||||
for line in lines:
|
||||
print(line)
|
||||
|
||||
# Extract calibration from STREAM Triad results
|
||||
measured_peak = _parse_triad_peak(lines)
|
||||
if measured_peak > 0 and theo_peak_gbs > 0:
|
||||
calibration = measured_peak / theo_peak_gbs
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Measured peak (triad vec4): {measured_peak:.2f} GB/s")
|
||||
print(f"Theoretical: {theo_peak_gbs:.1f} GB/s")
|
||||
print(f"Calibration: {calibration:.4f}")
|
||||
print(f"\nUpdate tileops/perf/profiles/{args.profile}.yaml:")
|
||||
print(f" hbm.calibration: {calibration:.4f}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
// HBM Saturation Benchmark
|
||||
// Measures peak HBM bandwidth with vectorized CUDA kernels.
|
||||
// Uses STREAM Triad [1] as the primary calibration pattern for roofline
|
||||
// analysis [2], with Copy/Read/Write as reference measurements.
|
||||
//
|
||||
// Triad (a[i] = b[i] + s*c[i]) has a 2:1 read:write ratio — closer to real
|
||||
// compute kernels than pure Copy (1:1), which suffers worst-case HBM bus
|
||||
// turnaround overhead. BabelStream [3] establishes this methodology for GPUs.
|
||||
//
|
||||
// References:
|
||||
// [1] McCalpin, J.D., 1995. "Memory Bandwidth and Machine Balance in Current
|
||||
// High Performance Computers." IEEE TCCA Newsletter, pp.19-25.
|
||||
// https://www.cs.virginia.edu/stream/
|
||||
// [2] Williams, S., Waterman, A. & Patterson, D., 2009. "Roofline: An
|
||||
// Insightful Visual Performance Model for Multicore Architectures."
|
||||
// Communications of the ACM, 52(4), pp.65-76.
|
||||
// [3] Deakin, T. et al., 2018. "Evaluating attainable memory bandwidth of
|
||||
// parallel programming models via BabelStream." International Journal of
|
||||
// Computational Science and Engineering, 17(3), pp.247-262.
|
||||
// https://github.com/UoB-HPC/BabelStream
|
||||
//
|
||||
// Compile: nvcc -O3 -arch=sm_90 -Wno-deprecated-gpu-targets -o hbm_saturation hbm_saturation.cu
|
||||
// Usage: ./hbm_saturation [size_mb] [theo_peak_gbs] (defaults: 2048, 4800)
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <cuda_runtime.h>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
#define CHECK_CUDA(call) do { \
|
||||
cudaError_t err = (call); \
|
||||
if (err != cudaSuccess) { \
|
||||
fprintf(stderr, "CUDA error at %s:%d: %s\n", __FILE__, __LINE__, cudaGetErrorString(err)); \
|
||||
exit(1); \
|
||||
} \
|
||||
} while(0)
|
||||
|
||||
#define CHECK_LAST() CHECK_CUDA(cudaGetLastError())
|
||||
|
||||
// ============================================================
|
||||
// Kernels
|
||||
// ============================================================
|
||||
|
||||
// Read kernels: use volatile to prevent compiler from optimizing away loads.
|
||||
// Accumulate into a register and write once to prevent the reduction overhead
|
||||
// from dominating measurement at small sizes.
|
||||
__global__ void k_read_vec4(const float4* __restrict__ data, volatile float* __restrict__ out, long long n) {
|
||||
float sum = 0.0f;
|
||||
long long idx = (long long)blockIdx.x * blockDim.x + threadIdx.x;
|
||||
long long stride = (long long)gridDim.x * blockDim.x;
|
||||
for (long long i = idx; i < n; i += stride) {
|
||||
float4 v = data[i];
|
||||
sum += v.x + v.y + v.z + v.w;
|
||||
}
|
||||
// Single atomic per warp — negligible overhead vs memory-bound loop above
|
||||
for (int o = 16; o > 0; o >>= 1) sum += __shfl_down_sync(0xffffffff, sum, o);
|
||||
if (threadIdx.x % 32 == 0) atomicAdd((float*)out, sum);
|
||||
}
|
||||
|
||||
// Write kernels
|
||||
__global__ void k_write_vec4(float4* __restrict__ data, long long n, float val) {
|
||||
long long idx = (long long)blockIdx.x * blockDim.x + threadIdx.x;
|
||||
long long stride = (long long)gridDim.x * blockDim.x;
|
||||
float4 v = make_float4(val, val, val, val);
|
||||
for (long long i = idx; i < n; i += stride) data[i] = v;
|
||||
}
|
||||
|
||||
// Copy kernels (read + write, 1:1 ratio)
|
||||
__global__ void k_copy_vec4(const float4* __restrict__ src, float4* __restrict__ dst, long long n) {
|
||||
long long idx = (long long)blockIdx.x * blockDim.x + threadIdx.x;
|
||||
long long stride = (long long)gridDim.x * blockDim.x;
|
||||
for (long long i = idx; i < n; i += stride) dst[i] = src[i];
|
||||
}
|
||||
|
||||
// STREAM Triad: a[i] = b[i] + scalar * c[i] (2 reads + 1 write, 2:1 ratio)
|
||||
// Industry-standard pattern for roofline calibration (closer to real kernel
|
||||
// memory access patterns than pure copy).
|
||||
__global__ void k_triad_vec4(const float4* __restrict__ b, const float4* __restrict__ c,
|
||||
float4* __restrict__ a, long long n, float scalar) {
|
||||
long long idx = (long long)blockIdx.x * blockDim.x + threadIdx.x;
|
||||
long long stride = (long long)gridDim.x * blockDim.x;
|
||||
for (long long i = idx; i < n; i += stride) {
|
||||
float4 vb = b[i];
|
||||
float4 vc = c[i];
|
||||
a[i] = make_float4(vb.x + scalar * vc.x, vb.y + scalar * vc.y,
|
||||
vb.z + scalar * vc.z, vb.w + scalar * vc.w);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Benchmark helper
|
||||
// ============================================================
|
||||
|
||||
struct BenchResult {
|
||||
float best_ms;
|
||||
float median_ms;
|
||||
float best_gbs;
|
||||
float median_gbs;
|
||||
};
|
||||
|
||||
BenchResult run_bench(std::function<void()> launch, long long total_bytes,
|
||||
int warmup = 100, int reps = 200) {
|
||||
std::vector<float> latencies;
|
||||
|
||||
for (int run = 0; run < 5; run++) {
|
||||
// Warmup: enough iterations for GPU clocks to stabilize
|
||||
for (int i = 0; i < warmup; i++) launch();
|
||||
CHECK_CUDA(cudaDeviceSynchronize());
|
||||
|
||||
cudaEvent_t t0, t1;
|
||||
CHECK_CUDA(cudaEventCreate(&t0));
|
||||
CHECK_CUDA(cudaEventCreate(&t1));
|
||||
CHECK_CUDA(cudaEventRecord(t0));
|
||||
for (int i = 0; i < reps; i++) launch();
|
||||
CHECK_CUDA(cudaEventRecord(t1));
|
||||
CHECK_CUDA(cudaEventSynchronize(t1));
|
||||
|
||||
float ms;
|
||||
CHECK_CUDA(cudaEventElapsedTime(&ms, t0, t1));
|
||||
latencies.push_back(ms / reps);
|
||||
|
||||
CHECK_CUDA(cudaEventDestroy(t0));
|
||||
CHECK_CUDA(cudaEventDestroy(t1));
|
||||
}
|
||||
|
||||
std::sort(latencies.begin(), latencies.end());
|
||||
float best = latencies[0];
|
||||
float median = latencies[2]; // middle of 5
|
||||
|
||||
auto to_gbs = [&](float ms) -> float {
|
||||
return (ms > 0) ? (float)total_bytes / (ms * 1e6f) : 0.0f;
|
||||
};
|
||||
|
||||
return {best, median, to_gbs(best), to_gbs(median)};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main
|
||||
// ============================================================
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
long long size_mb = 2048;
|
||||
float theo_peak_gbs = 4800.0f;
|
||||
if (argc >= 2) size_mb = atoll(argv[1]);
|
||||
if (argc >= 3) theo_peak_gbs = atof(argv[2]);
|
||||
|
||||
long long n_floats = size_mb * 1024LL * 1024LL / sizeof(float);
|
||||
long long nbytes = n_floats * sizeof(float);
|
||||
|
||||
cudaDeviceProp prop;
|
||||
cudaGetDeviceProperties(&prop, 0);
|
||||
int sm_count = prop.multiProcessorCount;
|
||||
|
||||
printf("GPU: %s | SMs: %d | L2: %d MB\n", prop.name, sm_count,
|
||||
prop.l2CacheSize / (1024*1024));
|
||||
printf("Working set: %lld MB (%lld float32 elements)\n", size_mb, n_floats);
|
||||
printf("Each config: 5 runs x 200 reps, warmup 100, reporting best and median\n");
|
||||
printf("theo_peak_gbs=%.1f\n\n", theo_peak_gbs);
|
||||
|
||||
// Allocate with cudaMalloc (guaranteed 256-byte aligned)
|
||||
// d_src, d_dst used by copy/read/write; d_src2 is the second input for triad
|
||||
float *d_src, *d_src2, *d_dst;
|
||||
volatile float *d_out;
|
||||
CHECK_CUDA(cudaMalloc(&d_src, nbytes));
|
||||
CHECK_CUDA(cudaMalloc(&d_src2, nbytes));
|
||||
CHECK_CUDA(cudaMalloc(&d_dst, nbytes));
|
||||
CHECK_CUDA(cudaMalloc((void**)&d_out, sizeof(float)));
|
||||
|
||||
// Initialize with a valid float pattern (not byte-fill).
|
||||
// Non-zero values prevent hardware memory compression from inflating results.
|
||||
{
|
||||
std::vector<float> host_data(n_floats, 1.0f);
|
||||
CHECK_CUDA(cudaMemcpy(d_src, host_data.data(), nbytes, cudaMemcpyHostToDevice));
|
||||
std::fill(host_data.begin(), host_data.end(), 2.0f);
|
||||
CHECK_CUDA(cudaMemcpy(d_src2, host_data.data(), nbytes, cudaMemcpyHostToDevice));
|
||||
CHECK_CUDA(cudaMemset(d_dst, 0, nbytes));
|
||||
CHECK_CUDA(cudaMemset((void*)d_out, 0, sizeof(float)));
|
||||
}
|
||||
|
||||
// CSV header
|
||||
printf("op,vec_width,block_size,best_ms,median_ms,best_gbs,median_gbs,pct_of_theo\n");
|
||||
|
||||
auto print_row = [theo_peak_gbs](const char* op, const char* vec, int bs, BenchResult r) {
|
||||
printf("%s,%s,%d,%.4f,%.4f,%.2f,%.2f,%.1f%%\n",
|
||||
op, vec, bs,
|
||||
r.best_ms, r.median_ms, r.best_gbs, r.median_gbs,
|
||||
r.best_gbs / theo_peak_gbs * 100.0f);
|
||||
};
|
||||
|
||||
// Block sizes to sweep
|
||||
int bss[] = {128, 256, 512};
|
||||
|
||||
// ============================================================
|
||||
// 1. Copy (read + write, 1:1 ratio) — reference
|
||||
// Bytes reported = 2 * nbytes (read src + write dst)
|
||||
// Note: 1:1 read:write creates worst-case bus turnaround pressure.
|
||||
// Real kernels are typically read-heavy and achieve higher bandwidth.
|
||||
// ============================================================
|
||||
printf("# Copy (read+write, 1:1 ratio) — reference\n");
|
||||
for (int bi = 0; bi < 3; bi++) {
|
||||
int bs = bss[bi];
|
||||
int nblocks = sm_count * (2048 / bs);
|
||||
long long n = n_floats / 4;
|
||||
auto launch = [&]() { k_copy_vec4<<<nblocks, bs>>>((float4*)d_src, (float4*)d_dst, n); CHECK_LAST(); };
|
||||
auto r = run_bench(launch, 2 * nbytes);
|
||||
print_row("copy", "vec4", bs, r);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. STREAM Triad: a[i] = b[i] + s*c[i] (2 reads + 1 write)
|
||||
// Bytes reported = 3 * nbytes (read b + read c + write a)
|
||||
// 2:1 read:write ratio — closer to real compute kernels than copy.
|
||||
// Used as the primary calibration measurement.
|
||||
// ============================================================
|
||||
printf("# Triad (2 reads + 1 write) — primary calibration\n");
|
||||
for (int bi = 0; bi < 3; bi++) {
|
||||
int bs = bss[bi];
|
||||
int nblocks = sm_count * (2048 / bs);
|
||||
long long n = n_floats / 4;
|
||||
auto launch = [&]() {
|
||||
k_triad_vec4<<<nblocks, bs>>>((float4*)d_src, (float4*)d_src2,
|
||||
(float4*)d_dst, n, 3.14f);
|
||||
CHECK_LAST();
|
||||
};
|
||||
auto r = run_bench(launch, 3 * nbytes);
|
||||
print_row("triad", "vec4", bs, r);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. Read-only (for reference, not used for calibration)
|
||||
// ============================================================
|
||||
printf("# Read-only — reference\n");
|
||||
for (int bi = 0; bi < 3; bi++) {
|
||||
int bs = bss[bi];
|
||||
int nblocks = sm_count * (2048 / bs);
|
||||
long long n = n_floats / 4;
|
||||
auto launch = [&]() { k_read_vec4<<<nblocks, bs>>>((float4*)d_src, d_out, n); CHECK_LAST(); };
|
||||
auto r = run_bench(launch, nbytes);
|
||||
print_row("read", "vec4", bs, r);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. Write-only (for reference, not used for calibration)
|
||||
// ============================================================
|
||||
printf("# Write-only — reference\n");
|
||||
for (int bi = 0; bi < 3; bi++) {
|
||||
int bs = bss[bi];
|
||||
int nblocks = sm_count * (2048 / bs);
|
||||
long long n = n_floats / 4;
|
||||
auto launch = [&]() { k_write_vec4<<<nblocks, bs>>>((float4*)d_dst, n, 1.0f); CHECK_LAST(); };
|
||||
auto r = run_bench(launch, nbytes);
|
||||
print_row("write", "vec4", bs, r);
|
||||
}
|
||||
|
||||
CHECK_CUDA(cudaFree(d_src));
|
||||
CHECK_CUDA(cudaFree(d_src2));
|
||||
CHECK_CUDA(cudaFree(d_dst));
|
||||
CHECK_CUDA(cudaFree((void*)d_out));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"""Benchmark for SharedExpertMLPMACAKernel vs PyTorch MLP."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from benchmarks.benchmark_base import BenchmarkBase, BenchmarkReport
|
||||
from tileops.kernels.moe import SharedExpertMLPMACAKernel
|
||||
from workloads.workload_base import FixtureBase, WorkloadBase
|
||||
|
||||
|
||||
class SharedMLPBenchTest(WorkloadBase):
|
||||
def __init__(self, num_tokens, hidden_size, ffn_size, dtype):
|
||||
self.num_tokens = num_tokens
|
||||
self.hidden_size = hidden_size
|
||||
self.ffn_size = ffn_size
|
||||
self.dtype = dtype
|
||||
|
||||
def gen_inputs(self):
|
||||
device = torch.device("cuda")
|
||||
hidden = torch.randn(self.num_tokens, self.hidden_size, dtype=self.dtype, device=device)
|
||||
w_gate_up = torch.randn(self.ffn_size * 2, self.hidden_size, dtype=self.dtype, device=device)
|
||||
w_down = torch.randn(self.hidden_size, self.ffn_size, dtype=self.dtype, device=device)
|
||||
return hidden, w_gate_up, w_down
|
||||
|
||||
|
||||
class SharedMLPBenchFixture(FixtureBase):
|
||||
PARAMS = [
|
||||
(
|
||||
"num_tokens, hidden_size, ffn_size, dtype",
|
||||
[
|
||||
pytest.param(512, 2048, 8192, torch.bfloat16, marks=pytest.mark.smoke),
|
||||
pytest.param(2048, 2048, 8192, torch.bfloat16, marks=pytest.mark.full),
|
||||
pytest.param(4096, 2048, 8192, torch.bfloat16, marks=pytest.mark.full),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class SharedMLPBenchmark(BenchmarkBase):
|
||||
def calculate_flops(self):
|
||||
t = self.workload
|
||||
return 2 * t.num_tokens * t.hidden_size * t.ffn_size * 3 # gate + up + down
|
||||
|
||||
def calculate_memory(self):
|
||||
t = self.workload
|
||||
elem = 2 # bf16
|
||||
return elem * (t.num_tokens * t.hidden_size + 3 * t.ffn_size * t.hidden_size + 3 * t.num_tokens * t.ffn_size)
|
||||
|
||||
|
||||
@SharedMLPBenchFixture
|
||||
def test_shared_mlp_bench(num_tokens, hidden_size, ffn_size, dtype):
|
||||
test = SharedMLPBenchTest(num_tokens, hidden_size, ffn_size, dtype)
|
||||
bm = SharedMLPBenchmark(test)
|
||||
hidden, w_gate_up, w_down = test.gen_inputs()
|
||||
|
||||
# TileLang kernel
|
||||
kernel = SharedExpertMLPMACAKernel(num_tokens=num_tokens, hidden_size=hidden_size,
|
||||
ffn_size=ffn_size, dtype=dtype)
|
||||
kernel(hidden, w_gate_up, w_down) # warmup
|
||||
torch.cuda.synchronize()
|
||||
|
||||
result = bm.profile(kernel, hidden, w_gate_up, w_down)
|
||||
BenchmarkReport.record(kernel, locals(), result, tag="tileops")
|
||||
|
||||
# PyTorch baseline
|
||||
def pytorch_fn(hidden, w_gate_up, w_down):
|
||||
gate = torch.nn.functional.linear(hidden, w_gate_up[:ffn_size])
|
||||
up = torch.nn.functional.linear(hidden, w_gate_up[ffn_size:])
|
||||
gate_up = torch.nn.functional.silu(gate) * up
|
||||
return torch.nn.functional.linear(gate_up, w_down)
|
||||
|
||||
pytorch_fn(hidden, w_gate_up, w_down) # warmup
|
||||
torch.cuda.synchronize()
|
||||
|
||||
result_torch = bm.profile(pytorch_fn, hidden, w_gate_up, w_down)
|
||||
BenchmarkReport.record(kernel, locals(), result_torch, tag="torch")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-m", "smoke"])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue