From c92eda004e706f65591323b1f3d4b29a5a35faaf Mon Sep 17 00:00:00 2001 From: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:04:34 +0800 Subject: [PATCH 1/5] [None][infra] CBTS: add coverage-DB audit as pre-merge shadow Add the read-only touch-DB accessor (touch_db.py, implementing TOUCH_DB_CONTRACT.md), an Artifactory fetch for the latest merged DB (artifact.py), and a standalone audit tool (coverage_audit.py) that reports the DB's health -- stage prefix, schema, scale, and the incomplete-capture ("untrusted") rate -- plus this HEAD's coverage gap: test cases that render on an instrumented single-GPU stage yet have no DB row, so the selector could never skip them. Wire an audit-first shadow step into getCbtsResult: each pre-merge /bot run downloads the latest merged touch DB and logs the audit. It is diagnostic only and does not change the CBTS decision -- coverage-based selection is not yet enforced; the same download will feed the selector in a follow-up. Everything is best-effort: a failed fetch or audit is logged and skipped, never blocking CI. Foundation for coverage-based test selection (CBTS Tier 2). Signed-off-by: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> --- jenkins/L0_MergeRequest.groovy | 36 +++ .../coverage_selection/TOUCH_DB_CONTRACT.md | 196 ++++++++++++++ .../cbts/coverage_selection/artifact.py | 197 ++++++++++++++ .../cbts/coverage_selection/touch_db.py | 244 ++++++++++++++++++ jenkins/scripts/cbts/tools/coverage_audit.py | 207 +++++++++++++++ 5 files changed, 880 insertions(+) create mode 100644 jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md create mode 100644 jenkins/scripts/cbts/coverage_selection/artifact.py create mode 100644 jenkins/scripts/cbts/coverage_selection/touch_db.py create mode 100644 jenkins/scripts/cbts/tools/coverage_audit.py diff --git a/jenkins/L0_MergeRequest.groovy b/jenkins/L0_MergeRequest.groovy index 57bc4123c2d5..765ae2340659 100644 --- a/jenkins/L0_MergeRequest.groovy +++ b/jenkins/L0_MergeRequest.groovy @@ -774,6 +774,12 @@ def getCbtsResult(pipeline, testFilter, globalVars) // pyyaml is needed by main.py's blocks.py to parse test-db YAMLs. sh "apt-get update -qq && apt-get install -y -qq python3-yaml" + // Coverage audit-first (shadow): download the latest merged touch DB and + // log its health + this HEAD's coverage gap. Diagnostic only — the + // decision below does NOT consume it (coverage selection stays off until + // the enforce step lands); the same download will feed selection then. + _cbtsCoverageAudit(pipeline) + // Ask Python which file patterns need diffs, fetch them. def patternsOut = sh( script: "cd ${LLM_ROOT} && python3 jenkins/scripts/cbts/main.py --list-needed-diffs", @@ -842,6 +848,36 @@ def getCbtsResult(pipeline, testFilter, globalVars) } } +// Download the latest merged touch DB and run coverage_audit.py on it, logging +// the DB's health + this HEAD's coverage gap. Best-effort and side-effect free: +// it never changes the CBTS decision. Uses the same Artifactory artifact the +// enforce step will later feed to the selector, so this validates the data path. +def _cbtsCoverageAudit(pipeline) +{ + try { + def covDir = "${LLM_ROOT}/cbts_cov" + def url = sh( + script: "cd ${LLM_ROOT} && python3 jenkins/scripts/cbts/coverage_selection/artifact.py --print-url", + returnStdout: true, + ).trim() + if (!url) { + pipeline.echo("CBTS audit: no coverage DB artifact found — skipping") + return + } + sh "mkdir -p ${covDir}" + // wget via the CI's proven retrying path (large artifact); extract the sqlite. + trtllm_utils.llmExecStepWithRetry(pipeline, script: + "wget -nv '${url}' -O ${covDir}/cbts_pystart_report.tar.gz && " + + "tar xzf ${covDir}/cbts_pystart_report.tar.gz -C ${covDir}") + sh "cd ${LLM_ROOT} && python3 jenkins/scripts/cbts/tools/coverage_audit.py " + + "--db ${covDir}/cbts_touchmap.sqlite" + } catch (InterruptedException e) { + throw e + } catch (Exception e) { + pipeline.echo("CBTS audit: skipped (non-fatal): ${e.message}") + } +} + // Post one CBTS decision record to OpenSearch (best-effort; never blocks CI). // decisionJson null for deferred; reason used only then. Context/creds via env. def _cbtsReportDecision(pipeline, globalVars, String status, String reason, String decisionJson) diff --git a/jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md b/jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md new file mode 100644 index 000000000000..78fe7f53d212 --- /dev/null +++ b/jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md @@ -0,0 +1,196 @@ +# CBTS Touch DB — Interface Contract (coverage-based test selection) + +Contract between the **coverage producer** (`cbts-coverage-utils` branch: +`jenkins/scripts/cbts/coverage_utils/`) and the **coverage-based selector** developed here. +The selector depends only on this contract, not on the producer's code, so the two branches +can land independently. + +Source of truth for every claim below (producer, `cbts-coverage-utils` branch): +- `coverage_utils/pystart_report.py` — builds the merged touch DB. +- `coverage_utils/cbts_pystart.py` — per-process `sys.monitoring` PY_START tracker. + +--- + +## 1. The artifact the selector consumes + +- **File:** `cbts_touchmap.sqlite` — the **merged, deduped, indexed** touch DB + (`pystart_report.py --out-sqlite`, described there as "indexed touch(test,file,qualname) DB for the selector"). +- **Packaging / retrieval:** uploaded per post-merge run as + `…///cbts-coverage/cbts_pystart_report.tar.gz`, which contains + `cbts_touchmap.sqlite` + `cbts_report/`. Extract the `.sqlite`; open **read-only**. +- **Do NOT consume** the per-process `.cbtscov...pid.X.sqlite` files: + they carry **raw absolute paths** and are **not deduped**. Only the merged DB is canonicalized + and indexed. (Per-process schema is `touch(test, file, qualname)` with no constraints.) + +--- + +## 2. Schema (merged `cbts_touchmap.sqlite`) + +```sql +CREATE TABLE touch ( + test TEXT, -- pytest nodeid that entered the function ('' == import-time / no test context) + file TEXT, -- product-relative path, canonicalized to 'tensorrt_llm/...' + qualname TEXT, -- co_qualname of the entered function/method (see §4) + UNIQUE(test, file, qualname) -- rows are deduped; no frequency/count is available +); +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + +CREATE INDEX ix_file ON touch(file); -- file -> tests +CREATE INDEX ix_func ON touch(file, qualname); -- (file, qualname) -> tests +CREATE INDEX ix_test ON touch(test); -- test -> touched +``` + +A row `(test, file, qualname)` means: **during an instrumented run, test `test` called the +function `qualname` defined in `file`.** PY_START fires on function *entry* (call), not on +line execution or import. + +--- + +## 3. Path normalization (`file`) — the selector MUST replicate this + +`file` in the merged DB is the producer's `canon()` of the absolute `co_filename`: + +```python +import re +def canon(path): + m = re.search(r"(tensorrt_llm/.*)$", path) + return m.group(1) if m else path +``` + +So values look like `tensorrt_llm/_torch/pyexecutor/py_executor.py`. + +**Join rule:** before querying, canonicalize the changed-file paths the *same* way. +Git-relative paths that already start with `tensorrt_llm/` are already canonical. +A changed path with no `tensorrt_llm/` segment (C++, configs, tests, tools) can **never** +match a `touch.file` — see the fail-safe rule in §7. + +--- + +## 4. `qualname` semantics + +`qualname == code.co_qualname`, recorded only when it passes the producer filter +(`cbts_pystart.py`): + +- **Excluded:** any name containing `` (nested/closure functions), and + `{, , , }` (dict/list/set comps + genexprs + lambdas). +- **Included forms:** `foo` (module-level), `Bar.baz` (method), `Outer.Inner.m` + (method of a nested class), and `` (module-body execution). + Control-flow blocks (`if`/`try`/`with`) do **not** appear in the name; class scopes prepend + `ClassName.`; crossing a function scope injects `..` (hence excluded). + +Granularity guidance: +- **File-level selection** (`WHERE file = ?`) is the robust default. +- **Function-level selection** (`WHERE file = ? AND qualname = ?`) is *best-effort*: closures, + comprehensions and lambdas are invisible, so a change confined to those maps only at file level. + +--- + +## 5. `meta` table (advisory stats, not required for selection) + +Keys currently written by `pystart_report.py`: + +| key | meaning | +|-----|---------| +| `tests` | distinct `test` where `test != ''` | +| `files` | distinct `file` where `test != ''` | +| `functions` | distinct `(file, qualname)` where `test != ''` | +| `file_rate_pct`, `func_rate_pct` | coverage rate vs `--source-root` denominator (only if that arg was passed) | +| `total_files`, `total_functions` | denominator sizes (only if `--source-root` was passed) | + +All values are **strings**. Treat every key as **optional** (read with a default) — the rate +keys are absent when the report is generated without `--source-root`. +There is **no `schema_version` key yet**; see §8. + +--- + +## 6. Consumer query patterns + +```sql +-- Reverse lookup — the core of selection. Always filter test != ''. +SELECT DISTINCT test FROM touch WHERE file = :file AND test != ''; -- file -> tests +SELECT DISTINCT test FROM touch WHERE file = :file AND qualname = :q AND test != ''; -- func -> tests + +-- Forward (debug / explain-why): +SELECT file, qualname FROM touch WHERE test = :test; + +-- Universe of tests that have coverage data at all: +SELECT DISTINCT test FROM touch WHERE test != ''; +``` + +**Always append `test != ''`.** Rows with `test == ''` are import-time / no-context +attributions (module bodies loaded before any test), not per-test signal. + +--- + +## 7. Selection algorithm contract + +Input: set of changed `(file[, qualname])` from `git diff` (+ AST for function granularity). +Output: set of pytest nodeids to run. + +``` +selected = ∅ +for each changed product file f (canonicalized to tensorrt_llm/...): + selected ∪= { test : (test, f, *) in touch, test != '' } # file-level, safe default +return selected +``` + +**Fail-safe (correctness > savings — an undercount silently drops tests → escapes):** +- A changed path **not** under `tensorrt_llm/` (C++/CUDA, YAML, tests, tooling, build) has **no** + Python coverage → **cannot be decided by this DB** → fall back to "run" (defer to the + rules-based selector / full set). Never treat "no match" as "skip". +- A changed product file with **zero** `touch` rows → treat as **unknown → run**, not "untested → skip" + (it may only be instrumented in a stage this DB didn't cover — see §9). +- A renamed/moved function will not match its new `qualname`/`file` → treat rename as "run". + +The DB tells you which tests to **keep**; it is not authoritative about which to **drop**. + +--- + +## 8. Versioning & stability + +- The schema above is the v1 contract. Consumers should be tolerant: **select named columns** + (`SELECT test, file, qualname …`), never `SELECT *`; read `meta` keys with defaults. +- **Recommended producer addition (not yet present):** a `meta` row + `('schema_version', '1')` so the selector can hard-fail on an unknown version instead of + silently mis-selecting. Track this as a producer-side follow-up on `cbts-coverage-utils`. + +--- + +## 9. Coverage scope & guarantees (read before trusting "no test hit this") + +**Guarantee:** if test `T` entered function `F` (`file`, `qualname`) during an instrumented run +and the process's periodic/atexit save succeeded, then `(T, canon(file), qualname)` is present. + +**Non-guarantees (all imply fail-safe → run):** +- **Instrumentation is gated** (`L0_Test.groovy::isCbtsStage`, Phase 1): only **single-GPU**, + **non-Perf / non-TensorRT / non-CPP / non-AutoDeploy**, **post-merge** stages are instrumented. + Any test outside that set has **no** coverage data here. +- **Call-based, not import-based:** functions imported but never called are absent; module-level + side-effect code is attributed to `test == ''` (import time), not to a specific test. +- **Closures / comprehensions / lambdas** are not recorded (§4). +- **No C++/CUDA coverage** at all — those changes are out of scope for this DB. +- **Dedup ⇒ no counts:** you cannot rank tests by hit frequency from this DB. +- **Staleness:** the DB reflects the code at collection time; drift (renames, new functions) + is invisible until recollected. + +--- + +## 10. Local fixture for developing against this contract + +Until the producer lands in `main`, build a fixture DB with the exact schema above: + +```sql +CREATE TABLE touch (test TEXT, file TEXT, qualname TEXT, UNIQUE(test, file, qualname)); +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); +CREATE INDEX ix_file ON touch(file); +CREATE INDEX ix_func ON touch(file, qualname); +CREATE INDEX ix_test ON touch(test); +INSERT OR IGNORE INTO touch VALUES + ('accuracy/test_llm_api_pytorch.py::TestLlama3_1_8B::test_nvfp4', + 'tensorrt_llm/_torch/pyexecutor/py_executor.py', 'PyExecutor._forward_step'), + ('accuracy/test_llm_api_pytorch.py::TestLlama3_1_8B::test_nvfp4', + 'tensorrt_llm/_torch/pyexecutor/py_executor.py', ''); +INSERT OR REPLACE INTO meta VALUES ('tests','1'),('files','1'),('functions','1'); +``` + +Wire the selector to open this read-only and exercise the §6 queries + §7 fail-safe paths. diff --git a/jenkins/scripts/cbts/coverage_selection/artifact.py b/jenkins/scripts/cbts/coverage_selection/artifact.py new file mode 100644 index 000000000000..82b3abbfbe53 --- /dev/null +++ b/jenkins/scripts/cbts/coverage_selection/artifact.py @@ -0,0 +1,197 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Resolve and fetch the latest merged CBTS touch DB from Artifactory. + +The producer's post-merge `Test Coverage` stage merges every stage's PY_START +files into `cbts_touchmap.sqlite`, tars it as `cbts_pystart_report.tar.gz` +(sqlite at the tar root plus `cbts_report/`), and uploads it to +`//cbts-coverage/` (see L0_MergeRequest.groovy Test +Coverage stage; ARTIFACT_BASE mirrors L0_Test.groovy UPLOAD_PATH). + +`latest_tarball_url()` finds the newest post-merge build that actually has the +tarball: it reads the latest build number from the Jenkins REST API (the only +in-repo precedent, get_image_key_to_tag.py) then walks builds down, probing +Artifactory with a 1-byte ranged GET until one exists — a build can be SUCCESS +yet upload nothing (no PY_START files -> early return), so `lastSuccessfulBuild` +alone is wrong. + +Two entry points for the Groovy wiring: + * `--print-url` — resolve and print the tarball URL only (no 1 GB download), + so the caller can `wget` it through the CI's proven download path. + * `--dest DIR` — download + extract, printing the local sqlite path. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import tarfile +import urllib.error +import urllib.request +from pathlib import Path +from typing import Optional + +# Base for the merged artifact — mirrors L0_Test.groovy UPLOAD_PATH +# (`sw-tensorrt-generic/llm-artifacts/${JOB_NAME}/${BUILD_NUMBER}`) for the +# main-branch L0_PostMerge job. Reads resolve through the virtual repo, same as +# the cbts_test_db download in L0_Test.groovy. +ARTIFACT_BASE = "sw-tensorrt-generic/llm-artifacts/LLM/main/L0_PostMerge" +TARBALL_NAME = "cbts_pystart_report.tar.gz" +SQLITE_NAME = "cbts_touchmap.sqlite" + +_URM = "https://urm.nvidia.com/artifactory" +_JENKINS_BASE = "https://prod.blsm.nvidia.com/sw-tensorrt-top-1/job/LLM/job/main/job/L0_PostMerge" +# How far back to walk when recent builds have no tarball (keeps a bounded probe). +_MAX_PROBE = 50 +# Per-request timeout so one stalled endpoint can't hang the whole probe walk. +_TIMEOUT = 15 + + +def _get(url: str) -> tuple[Optional[int], Optional[bytes]]: + try: + with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, None + except Exception as e: # noqa: BLE001 — network best-effort; caller falls back + print(f"[artifact] error fetching {url}: {e}", file=sys.stderr) + return None, None + + +def _exists(url: str) -> bool: + """True if the artifact exists — a 1-byte ranged GET (works where HEAD may not). + + HEAD is not guaranteed on Artifactory; the proven precedent uses GET. `Range: + bytes=0-0` keeps the body ~1 byte (not the full 1 GB); a present artifact + answers 206 (or 200 if the range is ignored). + """ + req = urllib.request.Request(url, headers={"Range": "bytes=0-0"}) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + return resp.status in (200, 206) + except urllib.error.HTTPError as e: + return e.code in (200, 206) + except Exception as e: # noqa: BLE001 + print(f"[artifact] error probing {url}: {e}", file=sys.stderr) + return False + + +def latest_build_number(jenkins_base: str = _JENKINS_BASE) -> Optional[int]: + """Newest build number via the Jenkins REST API (lastBuild, then lastCompletedBuild).""" + for kind in ("lastBuild", "lastCompletedBuild"): + status, data = _get(f"{jenkins_base}/{kind}/api/json") + if status == 200 and data: + try: + return int(json.loads(data)["number"]) + except (json.JSONDecodeError, KeyError, ValueError): + pass + return None + + +def tarball_url(build: int, artifact_base: str = ARTIFACT_BASE) -> str: + return f"{_URM}/{artifact_base}/{build}/cbts-coverage/{TARBALL_NAME}" + + +def latest_tarball_url( + artifact_base: str = ARTIFACT_BASE, + jenkins_base: str = _JENKINS_BASE, + max_probe: int = _MAX_PROBE, +) -> Optional[str]: + """URL of the newest build whose coverage tarball actually exists, or None.""" + build = latest_build_number(jenkins_base) + if build is None: + print("[artifact] could not resolve latest build number", file=sys.stderr) + return None + floor = max(0, build - max_probe) + while build > floor: + url = tarball_url(build, artifact_base) + if _exists(url): + return url + print(f"[artifact] build {build} has no tarball, trying {build - 1}", file=sys.stderr) + build -= 1 + print(f"[artifact] no tarball in the last {max_probe} builds", file=sys.stderr) + return None + + +def extract_touch_db(tarball: Path | str, dest_dir: Path | str) -> Optional[Path]: + """Extract `cbts_touchmap.sqlite` from a downloaded tarball; return its path.""" + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + with tarfile.open(tarball) as tf: + member = next((m for m in tf.getmembers() if m.name.endswith(SQLITE_NAME)), None) + if member is None: + return None + member.name = SQLITE_NAME + tf.extract(member, dest_dir) + return dest_dir / SQLITE_NAME + + +def fetch_latest_touch_db(dest_dir: Path | str, url: Optional[str] = None) -> Optional[Path]: + """Download + extract the latest post-merge touch DB; return local sqlite Path or None. + + `url` pins an explicit tarball (skips latest-build resolution). Best-effort: + any failure returns None so the caller falls back to coverage-off. + """ + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + url = url or latest_tarball_url() + if url is None: + return None + tarball = dest_dir / TARBALL_NAME + try: + with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp, open(tarball, "wb") as f: + shutil.copyfileobj(resp, f) + except Exception as e: # noqa: BLE001 + print(f"[artifact] download failed {url}: {e}", file=sys.stderr) + return None + return extract_touch_db(tarball, dest_dir) + + +def main(argv: Optional[list[str]] = None) -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--dest", help="download + extract into DIR; prints the local sqlite path") + ap.add_argument( + "--print-url", action="store_true", help="resolve and print the tarball URL only" + ) + ap.add_argument( + "--build", type=int, default=None, help="pin a build number (skip auto-resolve)" + ) + args = ap.parse_args(argv) + + url = tarball_url(args.build) if args.build is not None else None + + if args.print_url: + url = url or latest_tarball_url() + if url is None: + return 1 + print(url) + return 0 + + if args.dest: + path = fetch_latest_touch_db(args.dest, url=url) + if path is None: + return 1 + print(path) + return 0 + + ap.error("one of --print-url or --dest is required") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/jenkins/scripts/cbts/coverage_selection/touch_db.py b/jenkins/scripts/cbts/coverage_selection/touch_db.py new file mode 100644 index 000000000000..09e25a701014 --- /dev/null +++ b/jenkins/scripts/cbts/coverage_selection/touch_db.py @@ -0,0 +1,244 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Read-only accessor for the merged CBTS touch DB (`cbts_touchmap.sqlite`). + +Implements the TOUCH_DB_CONTRACT.md queries, plus the two real-data details +the contract text predates: + - the `test` column is `/` (stage-prefixed); + - unit tests are recorded wrapped as + `test_unittests.py::test_unittests_v2[]`, while test-db YAML lists + the bare `` entry. +""" + +from __future__ import annotations + +import re +import sqlite3 +from pathlib import Path +from typing import Optional + +from blocks import normalize_test_id + +# §3 producer canon(): keep the tail from the first `tensorrt_llm/` segment. +_CANON_RE = re.compile(r"(tensorrt_llm/.*)$") + +# A DB test value is `/`; unit tests wrap the inner entry. +_UNITTEST_WRAP_RE = re.compile(r"::test_unittests_v2\[(?P.+)\]$") + +# Coverage-completeness heuristic (interim, until the producer emits a per-test +# completeness/outcome signal). A test whose worker/server subprocess coverage +# was lost looks "clean but empty"; skipping it for an executor change would be +# an escape. `untrusted_tests()` uses these to force such tests to always run. +# They live here (not in the selector) so the audit tool and the selector share +# one source of truth for what "untrusted" means. +# +# A test that drove model execution but is missing the executor worker +# (`_WORKER_SENTINEL`) had its worker/server subprocess capture lost. Two ways +# to identify "drove execution", both footprint-independent: +# - `_LAUNCH_MARKERS` `(file, qualname_substring)`: a call-based signal — the +# LLM-API path enters `LLM.generate` / `GenerationExecutor.generate`. +# - `_SERVING_PATH_MARKERS` nodeid substrings: for disagg, whose coordinator +# runs no disagg-specific product code (only generic param validators — no +# clean call marker exists) and whose ctx/gen servers run in uninstrumented +# trtllm-serve subprocesses; identify it by nodeid path instead. +# `_MIN_FUNCS` is a last-resort catch-all for any other near-empty capture. +_WORKER_SENTINEL = "tensorrt_llm/_torch/pyexecutor/py_executor.py" +_LAUNCH_MARKERS: tuple[tuple[str, str], ...] = ( + ("tensorrt_llm/llmapi/llm.py", "generate"), + ("tensorrt_llm/executor/executor.py", "GenerationExecutor.generate"), +) +_SERVING_PATH_MARKERS: tuple[str, ...] = ("disaggregated/",) +_MIN_FUNCS = 30 + + +def canon(path: str) -> str: + """Canonicalize a path to the DB's `file` form (`tensorrt_llm/...`).""" + m = _CANON_RE.search(path) + return m.group(1) if m else path + + +def split_stage(test: str) -> tuple[str, str]: + """Split a DB `test` value `/` into `(stage, nodeid)`. + + Stage names carry no `/`; a nodeid always does (`dir/file.py::...`), so the + first `/` is the boundary. Returns `("", test)` when there is no `/`. + """ + stage, sep, nodeid = test.partition("/") + return (stage, nodeid) if sep else ("", test) + + +def unwrap_unittest(nodeid: str) -> Optional[str]: + """Return the inner `unittest/...` entry of a wrapped unittest nodeid, else None. + + `test_unittests.py::test_unittests_v2[unittest/x.py -m "part0"]` + -> `unittest/x.py -m "part0"` + """ + m = _UNITTEST_WRAP_RE.search(nodeid) + return m.group("inner") if m else None + + +def db_key(entry: str) -> Optional[str]: + """Map a test-db YAML `tests:` entry to the DB nodeid form, or None if not 1:1. + + The inverse of `unwrap_unittest`: unit tests wrap as + `test_unittests.py::test_unittests_v2[]`, and a `-k` keyword entry + expands to many nodeids at runtime (no single DB key) -> None. + """ + e = normalize_test_id(entry) + if e.startswith("unittest/"): + return f"test_unittests.py::test_unittests_v2[{e}]" + if " -k " in e: + return None + return e + + +class TouchDB: + """Read-only view over a merged `cbts_touchmap.sqlite`.""" + + def __init__(self, conn: sqlite3.Connection) -> None: + self._conn = conn + + @classmethod + def open(cls, sqlite_path: Path | str) -> "TouchDB": + """Open the DB read-only (`mode=ro`) and verify the `touch` schema.""" + uri = f"file:{Path(sqlite_path).resolve()}?mode=ro" + conn = sqlite3.connect(uri, uri=True) + cols = {row[1] for row in conn.execute("PRAGMA table_info(touch)")} + if not {"test", "file", "qualname"} <= cols: + conn.close() + raise ValueError(f"unexpected touch schema, columns={sorted(cols)}") + return cls(conn) + + def close(self) -> None: + self._conn.close() + + def __enter__(self) -> "TouchDB": + return self + + def __exit__(self, *_exc) -> None: + self.close() + + # -- meta (every key optional; read with a default) -- + + def meta(self, key: str, default: Optional[str] = None) -> Optional[str]: + row = self._conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() + return row[0] if row is not None else default + + def schema_version(self) -> Optional[str]: + return self.meta("schema_version") + + def collection_commit(self) -> Optional[str]: + """Commit the DB was collected at, for staleness gating (absent today).""" + return self.meta("commit") or self.meta("collection_commit") + + # -- reverse lookup (the core of selection); always `test != ''` -- + + def tests_touching_file(self, file: str) -> set[str]: + """Stage-prefixed tests that entered any function in `file` (file-level).""" + return { + row[0] + for row in self._conn.execute( + "SELECT DISTINCT test FROM touch WHERE file=? AND test!=''", (file,) + ) + } + + def tests_touching_func(self, file: str, qualname: str) -> set[str]: + """Stage-prefixed tests that entered `qualname` in `file` (function-level).""" + return { + row[0] + for row in self._conn.execute( + "SELECT DISTINCT test FROM touch WHERE file=? AND qualname=? AND test!=''", + (file, qualname), + ) + } + + def file_has_touch_rows(self, file: str) -> bool: + """True iff any instrumented test entered a function in `file`.""" + row = self._conn.execute( + "SELECT 1 FROM touch WHERE file=? AND test!='' LIMIT 1", (file,) + ).fetchone() + return row is not None + + # -- universe / per-stage -- + + def known_tests(self) -> set[str]: + """Every stage-prefixed test with coverage data.""" + return { + row[0] for row in self._conn.execute("SELECT DISTINCT test FROM touch WHERE test!=''") + } + + def instrumented_stages(self) -> set[str]: + """Stage names the DB has data for — the stages coverage may narrow.""" + return {stage for stage, _ in map(split_stage, self.known_tests()) if stage} + + def known_by_stage(self) -> dict[str, set[str]]: + """`{stage -> {bare nodeid, ...}}` over all known tests.""" + out: dict[str, set[str]] = {} + for test in self.known_tests(): + stage, nodeid = split_stage(test) + if stage: + out.setdefault(stage, set()).add(nodeid) + return out + + # -- forward lookup (debug / explain-why) -- + + def files_touched_by(self, test: str) -> list[tuple[str, str]]: + """`(file, qualname)` rows for a stage-prefixed `test`.""" + return [ + (row[0], row[1]) + for row in self._conn.execute("SELECT file, qualname FROM touch WHERE test=?", (test,)) + ] + + # -- coverage-completeness heuristic (interim, until the producer signals it) -- + + def untrusted_tests( + self, + worker_file: str, + launch_markers: tuple[tuple[str, str], ...], + serving_path_markers: tuple[str, ...], + min_funcs: int, + ) -> set[str]: + """Stage-prefixed tests whose per-test capture looks incomplete (must always run). + + Untrusted when the test drove model execution/serving but is missing the + executor `worker_file` (its worker/server process was not captured) — + identified either by entering a `launch_markers` `(file, qualname_substring)` + (call-based) or by a `serving_path_markers` nodeid substring (path-based, + for disagg) — OR when it entered fewer than `min_funcs` functions total + (a near-empty capture, last resort). + """ + drove_execution: set[str] = set() + for file, qual_substr in launch_markers: + drove_execution |= { + row[0] + for row in self._conn.execute( + "SELECT DISTINCT test FROM touch WHERE file=? AND qualname LIKE ? AND test!=''", + (file, f"%{qual_substr}%"), + ) + } + if serving_path_markers: + drove_execution |= { + test + for test in self.known_tests() + if any(marker in test for marker in serving_path_markers) + } + missing_worker = drove_execution - self.tests_touching_file(worker_file) + tiny = { + row[0] + for row in self._conn.execute( + "SELECT test FROM touch WHERE test!='' GROUP BY test HAVING COUNT(*) < ?", + (min_funcs,), + ) + } + return missing_worker | tiny diff --git a/jenkins/scripts/cbts/tools/coverage_audit.py b/jenkins/scripts/cbts/tools/coverage_audit.py new file mode 100644 index 000000000000..161b30711104 --- /dev/null +++ b/jenkins/scripts/cbts/tools/coverage_audit.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +r"""Audit a CBTS touch DB (`cbts_touchmap.sqlite`) — format, scale, and coverage completeness. + +Standalone: pull the sqlite locally and run this to see whether the data is +trustworthy before relying on it for selection. Reports the format (stage +prefix, schema_version, collection commit), scale, per-stage known counts, the +per-test footprint distribution, and the tests whose capture looks incomplete +(same heuristic the selector uses, so the "untrusted" set matches). + +Example:: + + python3 jenkins/scripts/cbts/tools/coverage_audit.py \\ + --db cbts_touchmap.sqlite --list-untrusted +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +THIS = Path(__file__).resolve() +CBTS = THIS.parent.parent +sys.path.insert(0, str(CBTS)) +sys.path.insert(0, str(CBTS / "coverage_selection")) + +from blocks import YAMLIndex, block_matches_stage, parse_stages_from_groovy # noqa: E402 +from touch_db import ( # noqa: E402 + _LAUNCH_MARKERS, + _MIN_FUNCS, + _SERVING_PATH_MARKERS, + _WORKER_SENTINEL, + TouchDB, + db_key, + split_stage, +) + +_DEFAULT_TEST_DB = CBTS.parents[2] / "tests/integration/test_lists/test-db" +_DEFAULT_GROOVY = CBTS.parents[2] / "jenkins/L0_Test.groovy" + + +def _fmt_pct(n: int, d: int) -> str: + return f"{100.0 * n / d:.0f}%" if d else "n/a" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--db", required=True, help="path to cbts_touchmap.sqlite") + ap.add_argument("--list-untrusted", action="store_true", help="print every untrusted test") + ap.add_argument( + "--min-funcs", type=int, default=_MIN_FUNCS, help=f"near-empty floor (default {_MIN_FUNCS})" + ) + ap.add_argument( + "--test-db", + default=str(_DEFAULT_TEST_DB), + help="test-db dir to diff against the DB (HEAD coverage gap); '' to skip", + ) + ap.add_argument( + "--groovy", default=str(_DEFAULT_GROOVY), help="Groovy file to parse stage defs from" + ) + ap.add_argument( + "--list-not-in-db", action="store_true", help="print every gap case (default: first 15)" + ) + args = ap.parse_args(argv) + + db = TouchDB.open(args.db) + known = db.known_tests() + stages = db.known_by_stage() + footprint = { + row[0]: row[1] + for row in db._conn.execute("SELECT test, COUNT(*) FROM touch WHERE test!='' GROUP BY test") + } + + print(f"=== CBTS coverage DB audit: {args.db} ===\n") + + # -- Format -- + print("## Format") + if stages: + print(f" test field: stage-prefixed ({len(stages)} instrumented stage(s) derivable)") + elif known: + print( + " test field: BARE nodeid !! WARNING: no stage prefix -> per-stage narrowing impossible" + ) + sv = db.schema_version() + commit = db.collection_commit() + print(f" schema_version: {sv or 'MISSING (selector cannot hard-fail on format drift)'}") + print( + f" collection commit: {commit or 'MISSING (no staleness gating; zero-touch lever stays off)'}" + ) + + # -- Scale -- + print("\n## Scale") + print( + f" known tests: {len(known)} | meta: tests={db.meta('tests')} files={db.meta('files')} " + f"functions={db.meta('functions')}" + ) + fr, qr = db.meta("file_rate_pct"), db.meta("func_rate_pct") + if fr or qr: + print(f" coverage rate: files {fr}% functions {qr}%") + + # -- Per-stage -- + print("\n## Instrumented stages") + for stage in sorted(stages): + print(f" {stage}: {len(stages[stage])} known") + + # -- Completeness -- + untrusted = db.untrusted_tests( + _WORKER_SENTINEL, _LAUNCH_MARKERS, _SERVING_PATH_MARKERS, args.min_funcs + ) + worker = db.tests_touching_file(_WORKER_SENTINEL) + + def reason(test: str) -> str: + if any(m in test for m in _SERVING_PATH_MARKERS): + return "disagg-path (servers uninstrumented)" + if test not in worker: + return "worker-lost (drove inference, no py_executor)" + return f"near-empty (<{args.min_funcs} funcs)" + + print("\n## Coverage completeness") + print( + f" per-test footprint (functions entered): min={min(footprint.values())} " + f"max={max(footprint.values())} (few funcs => likely lost subprocess capture)" + ) + trusted_fp = [footprint[t] for t in known if t not in untrusted] + untrusted_fp = [footprint[t] for t in untrusted] + if trusted_fp and untrusted_fp: + print( + f" footprint gap: untrusted max={max(untrusted_fp)} | trusted min={min(trusted_fp)}" + ) + print( + f" UNTRUSTED (incomplete capture): {len(untrusted)}/{len(known)} ({_fmt_pct(len(untrusted), len(known))})" + ) + by_reason: dict[str, int] = {} + by_stage: dict[str, int] = {} + for t in untrusted: + by_reason[reason(t)] = by_reason.get(reason(t), 0) + 1 + by_stage[split_stage(t)[0]] = by_stage.get(split_stage(t)[0], 0) + 1 + for r, n in sorted(by_reason.items(), key=lambda kv: -kv[1]): + print(f" - {r}: {n}") + print(f" by stage: {dict(sorted(by_stage.items()))}") + print( + f"\n TRUSTED skippable universe: {len(known) - len(untrusted)}/{len(known)} " + f"(only these may ever be skipped)" + ) + + if args.list_untrusted: + print("\n## Untrusted tests") + for t in sorted(untrusted): + print(f" [{footprint[t]:>5} funcs] {t}\n -> {reason(t)}") + + # -- HEAD coverage gap on instrumented stages -- + # Cases that render on a stage the DB *does* cover, yet have no DB row (a new/ + # renamed test, or one the producer never captured) -> the selector can never + # skip them. Cases only on non-instrumented stages (disagg, multi-GPU) are + # excluded: coverage never narrows those, so their absence is expected. + if args.test_db and Path(args.test_db).is_dir() and Path(args.groovy).is_file(): + yaml_index = YAMLIndex.load(Path(args.test_db)) + all_stages = parse_stages_from_groovy(Path(args.groovy), include_post_merge=True) + bare_known = {split_stage(t)[1] for t in known} + per_stage: dict[str, set[str]] = {} + for name in sorted(set(all_stages) & set(stages)): + stage = all_stages[name] + missing = { + entry + for block in yaml_index.blocks + if block.yaml_stem == stage.yaml_stem and block_matches_stage(block, stage) + for entry in block.tests + if (k := db_key(entry)) is not None and k not in bare_known + } + if missing: + per_stage[name] = missing + gap = sorted(set().union(*per_stage.values())) if per_stage else [] + print("\n## HEAD coverage gap on instrumented stages") + print( + f" {len(gap)} unique case(s) render on an instrumented single-GPU stage but have " + f"NO DB row -> always must-run (new/renamed or never captured)" + ) + for name in sorted(per_stage, key=lambda n: -len(per_stage[n])): + print(f" {name}: {len(per_stage[name])} not-in-DB") + preview = gap if args.list_not_in_db else gap[:15] + if preview: + print(" cases:") + for t in preview: + print(f" - {t}") + if not args.list_not_in_db and len(gap) > 15: + print(f" ... (+{len(gap) - 15}; use --list-not-in-db)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 25a0c8059fd8fb64719b5bf83708c0ace4cad6ca Mon Sep 17 00:00:00 2001 From: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:47:38 +0800 Subject: [PATCH 2/5] [None][infra] CBTS: harden coverage-audit and trim comments Address review findings on the coverage-DB audit shadow: - coverage_audit.py: guard the per-test footprint print so an empty or all-`test=''` DB reports "no usable per-test coverage" instead of crashing on min()/max(); classify near-empty vs worker-lost from the footprint so the by-reason histogram no longer mislabels tiny non-executor tests as worker-lost. - touch_db.py: TouchDB.meta() tolerates a missing `meta` table; add per_test_footprint() so the audit no longer reaches into the private `_conn`. - artifact.py: return None (not raise) when a downloaded tarball fails to extract, honoring the best-effort contract; drop the unreachable HTTPError 200/206 branch in _exists. - L0_MergeRequest.groovy: run `artifact.py --print-url || true` so a missing artifact takes the clean skip path instead of the generic catch. - Collapse multi-line comments and docstrings to one-line, current-behavior form. Signed-off-by: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> --- jenkins/L0_MergeRequest.groovy | 14 ++--- .../cbts/coverage_selection/artifact.py | 51 ++++++--------- .../cbts/coverage_selection/touch_db.py | 62 ++++++++----------- jenkins/scripts/cbts/tools/coverage_audit.py | 37 +++++------ 4 files changed, 62 insertions(+), 102 deletions(-) diff --git a/jenkins/L0_MergeRequest.groovy b/jenkins/L0_MergeRequest.groovy index 765ae2340659..6d936fad7a96 100644 --- a/jenkins/L0_MergeRequest.groovy +++ b/jenkins/L0_MergeRequest.groovy @@ -774,10 +774,7 @@ def getCbtsResult(pipeline, testFilter, globalVars) // pyyaml is needed by main.py's blocks.py to parse test-db YAMLs. sh "apt-get update -qq && apt-get install -y -qq python3-yaml" - // Coverage audit-first (shadow): download the latest merged touch DB and - // log its health + this HEAD's coverage gap. Diagnostic only — the - // decision below does NOT consume it (coverage selection stays off until - // the enforce step lands); the same download will feed selection then. + // Shadow audit: download the latest merged touch DB and log its health + HEAD coverage gap (diagnostic only). _cbtsCoverageAudit(pipeline) // Ask Python which file patterns need diffs, fetch them. @@ -848,16 +845,13 @@ def getCbtsResult(pipeline, testFilter, globalVars) } } -// Download the latest merged touch DB and run coverage_audit.py on it, logging -// the DB's health + this HEAD's coverage gap. Best-effort and side-effect free: -// it never changes the CBTS decision. Uses the same Artifactory artifact the -// enforce step will later feed to the selector, so this validates the data path. +// Download the latest merged touch DB and run coverage_audit.py on it; best-effort, never changes the CBTS decision. def _cbtsCoverageAudit(pipeline) { try { def covDir = "${LLM_ROOT}/cbts_cov" def url = sh( - script: "cd ${LLM_ROOT} && python3 jenkins/scripts/cbts/coverage_selection/artifact.py --print-url", + script: "cd ${LLM_ROOT} && python3 jenkins/scripts/cbts/coverage_selection/artifact.py --print-url || true", returnStdout: true, ).trim() if (!url) { @@ -865,7 +859,7 @@ def _cbtsCoverageAudit(pipeline) return } sh "mkdir -p ${covDir}" - // wget via the CI's proven retrying path (large artifact); extract the sqlite. + // wget the tarball (retrying) and extract the sqlite. trtllm_utils.llmExecStepWithRetry(pipeline, script: "wget -nv '${url}' -O ${covDir}/cbts_pystart_report.tar.gz && " + "tar xzf ${covDir}/cbts_pystart_report.tar.gz -C ${covDir}") diff --git a/jenkins/scripts/cbts/coverage_selection/artifact.py b/jenkins/scripts/cbts/coverage_selection/artifact.py index 82b3abbfbe53..be5fd585ecb3 100644 --- a/jenkins/scripts/cbts/coverage_selection/artifact.py +++ b/jenkins/scripts/cbts/coverage_selection/artifact.py @@ -13,22 +13,16 @@ # limitations under the License. """Resolve and fetch the latest merged CBTS touch DB from Artifactory. -The producer's post-merge `Test Coverage` stage merges every stage's PY_START -files into `cbts_touchmap.sqlite`, tars it as `cbts_pystart_report.tar.gz` -(sqlite at the tar root plus `cbts_report/`), and uploads it to -`//cbts-coverage/` (see L0_MergeRequest.groovy Test -Coverage stage; ARTIFACT_BASE mirrors L0_Test.groovy UPLOAD_PATH). - -`latest_tarball_url()` finds the newest post-merge build that actually has the -tarball: it reads the latest build number from the Jenkins REST API (the only -in-repo precedent, get_image_key_to_tag.py) then walks builds down, probing -Artifactory with a 1-byte ranged GET until one exists — a build can be SUCCESS -yet upload nothing (no PY_START files -> early return), so `lastSuccessfulBuild` -alone is wrong. +The tarball is uploaded per post-merge run to +`//cbts-coverage/cbts_pystart_report.tar.gz` (sqlite at +the tar root plus `cbts_report/`). + +`latest_tarball_url()` reads the newest build number from the Jenkins REST API, +then walks builds down, probing Artifactory with a 1-byte ranged GET until it +finds one whose tarball exists. Two entry points for the Groovy wiring: - * `--print-url` — resolve and print the tarball URL only (no 1 GB download), - so the caller can `wget` it through the CI's proven download path. + * `--print-url` — resolve and print the tarball URL only (no download). * `--dest DIR` — download + extract, printing the local sqlite path. """ @@ -44,19 +38,16 @@ from pathlib import Path from typing import Optional -# Base for the merged artifact — mirrors L0_Test.groovy UPLOAD_PATH -# (`sw-tensorrt-generic/llm-artifacts/${JOB_NAME}/${BUILD_NUMBER}`) for the -# main-branch L0_PostMerge job. Reads resolve through the virtual repo, same as -# the cbts_test_db download in L0_Test.groovy. +# Merged-artifact base for the main-branch L0_PostMerge job. ARTIFACT_BASE = "sw-tensorrt-generic/llm-artifacts/LLM/main/L0_PostMerge" TARBALL_NAME = "cbts_pystart_report.tar.gz" SQLITE_NAME = "cbts_touchmap.sqlite" _URM = "https://urm.nvidia.com/artifactory" _JENKINS_BASE = "https://prod.blsm.nvidia.com/sw-tensorrt-top-1/job/LLM/job/main/job/L0_PostMerge" -# How far back to walk when recent builds have no tarball (keeps a bounded probe). +# Max builds to walk back when recent builds have no tarball. _MAX_PROBE = 50 -# Per-request timeout so one stalled endpoint can't hang the whole probe walk. +# Per-request timeout in seconds. _TIMEOUT = 15 @@ -66,24 +57,19 @@ def _get(url: str) -> tuple[Optional[int], Optional[bytes]]: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, None - except Exception as e: # noqa: BLE001 — network best-effort; caller falls back + except Exception as e: # noqa: BLE001 print(f"[artifact] error fetching {url}: {e}", file=sys.stderr) return None, None def _exists(url: str) -> bool: - """True if the artifact exists — a 1-byte ranged GET (works where HEAD may not). - - HEAD is not guaranteed on Artifactory; the proven precedent uses GET. `Range: - bytes=0-0` keeps the body ~1 byte (not the full 1 GB); a present artifact - answers 206 (or 200 if the range is ignored). - """ + """True if the artifact exists — a 1-byte ranged GET; 200/206 means present.""" req = urllib.request.Request(url, headers={"Range": "bytes=0-0"}) try: with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: return resp.status in (200, 206) - except urllib.error.HTTPError as e: - return e.code in (200, 206) + except urllib.error.HTTPError: + return False except Exception as e: # noqa: BLE001 print(f"[artifact] error probing {url}: {e}", file=sys.stderr) return False @@ -142,8 +128,7 @@ def extract_touch_db(tarball: Path | str, dest_dir: Path | str) -> Optional[Path def fetch_latest_touch_db(dest_dir: Path | str, url: Optional[str] = None) -> Optional[Path]: """Download + extract the latest post-merge touch DB; return local sqlite Path or None. - `url` pins an explicit tarball (skips latest-build resolution). Best-effort: - any failure returns None so the caller falls back to coverage-off. + `url` pins an explicit tarball (skips latest-build resolution); any failure returns None. """ dest_dir = Path(dest_dir) dest_dir.mkdir(parents=True, exist_ok=True) @@ -154,10 +139,10 @@ def fetch_latest_touch_db(dest_dir: Path | str, url: Optional[str] = None) -> Op try: with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp, open(tarball, "wb") as f: shutil.copyfileobj(resp, f) + return extract_touch_db(tarball, dest_dir) except Exception as e: # noqa: BLE001 - print(f"[artifact] download failed {url}: {e}", file=sys.stderr) + print(f"[artifact] download/extract failed {url}: {e}", file=sys.stderr) return None - return extract_touch_db(tarball, dest_dir) def main(argv: Optional[list[str]] = None) -> int: diff --git a/jenkins/scripts/cbts/coverage_selection/touch_db.py b/jenkins/scripts/cbts/coverage_selection/touch_db.py index 09e25a701014..d6ff12abc686 100644 --- a/jenkins/scripts/cbts/coverage_selection/touch_db.py +++ b/jenkins/scripts/cbts/coverage_selection/touch_db.py @@ -13,8 +13,7 @@ # limitations under the License. """Read-only accessor for the merged CBTS touch DB (`cbts_touchmap.sqlite`). -Implements the TOUCH_DB_CONTRACT.md queries, plus the two real-data details -the contract text predates: +Implements the TOUCH_DB_CONTRACT.md queries. Two real-data details: - the `test` column is `/` (stage-prefixed); - unit tests are recorded wrapped as `test_unittests.py::test_unittests_v2[]`, while test-db YAML lists @@ -30,29 +29,13 @@ from blocks import normalize_test_id -# §3 producer canon(): keep the tail from the first `tensorrt_llm/` segment. +# Canonicalize an absolute co_filename to the DB `file` form (`tensorrt_llm/...`). _CANON_RE = re.compile(r"(tensorrt_llm/.*)$") # A DB test value is `/`; unit tests wrap the inner entry. _UNITTEST_WRAP_RE = re.compile(r"::test_unittests_v2\[(?P.+)\]$") -# Coverage-completeness heuristic (interim, until the producer emits a per-test -# completeness/outcome signal). A test whose worker/server subprocess coverage -# was lost looks "clean but empty"; skipping it for an executor change would be -# an escape. `untrusted_tests()` uses these to force such tests to always run. -# They live here (not in the selector) so the audit tool and the selector share -# one source of truth for what "untrusted" means. -# -# A test that drove model execution but is missing the executor worker -# (`_WORKER_SENTINEL`) had its worker/server subprocess capture lost. Two ways -# to identify "drove execution", both footprint-independent: -# - `_LAUNCH_MARKERS` `(file, qualname_substring)`: a call-based signal — the -# LLM-API path enters `LLM.generate` / `GenerationExecutor.generate`. -# - `_SERVING_PATH_MARKERS` nodeid substrings: for disagg, whose coordinator -# runs no disagg-specific product code (only generic param validators — no -# clean call marker exists) and whose ctx/gen servers run in uninstrumented -# trtllm-serve subprocesses; identify it by nodeid path instead. -# `_MIN_FUNCS` is a last-resort catch-all for any other near-empty capture. +# Completeness-heuristic constants consumed by `untrusted_tests()`. _WORKER_SENTINEL = "tensorrt_llm/_torch/pyexecutor/py_executor.py" _LAUNCH_MARKERS: tuple[tuple[str, str], ...] = ( ("tensorrt_llm/llmapi/llm.py", "generate"), @@ -69,11 +52,7 @@ def canon(path: str) -> str: def split_stage(test: str) -> tuple[str, str]: - """Split a DB `test` value `/` into `(stage, nodeid)`. - - Stage names carry no `/`; a nodeid always does (`dir/file.py::...`), so the - first `/` is the boundary. Returns `("", test)` when there is no `/`. - """ + """Split a DB `test` value `/` into `(stage, nodeid)`; `("", test)` if no `/`.""" stage, sep, nodeid = test.partition("/") return (stage, nodeid) if sep else ("", test) @@ -91,9 +70,8 @@ def unwrap_unittest(nodeid: str) -> Optional[str]: def db_key(entry: str) -> Optional[str]: """Map a test-db YAML `tests:` entry to the DB nodeid form, or None if not 1:1. - The inverse of `unwrap_unittest`: unit tests wrap as - `test_unittests.py::test_unittests_v2[]`, and a `-k` keyword entry - expands to many nodeids at runtime (no single DB key) -> None. + Unit tests wrap as `test_unittests.py::test_unittests_v2[]`; a `-k` + keyword entry expands to many nodeids (no single DB key) -> None. """ e = normalize_test_id(entry) if e.startswith("unittest/"): @@ -132,14 +110,17 @@ def __exit__(self, *_exc) -> None: # -- meta (every key optional; read with a default) -- def meta(self, key: str, default: Optional[str] = None) -> Optional[str]: - row = self._conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() + try: + row = self._conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() + except sqlite3.OperationalError: + return default return row[0] if row is not None else default def schema_version(self) -> Optional[str]: return self.meta("schema_version") def collection_commit(self) -> Optional[str]: - """Commit the DB was collected at, for staleness gating (absent today).""" + """Commit the DB was collected at, or None if not recorded.""" return self.meta("commit") or self.meta("collection_commit") # -- reverse lookup (the core of selection); always `test != ''` -- @@ -178,6 +159,15 @@ def known_tests(self) -> set[str]: row[0] for row in self._conn.execute("SELECT DISTINCT test FROM touch WHERE test!=''") } + def per_test_footprint(self) -> dict[str, int]: + """`{test -> functions entered}` over all stage-prefixed tests.""" + return { + row[0]: row[1] + for row in self._conn.execute( + "SELECT test, COUNT(*) FROM touch WHERE test!='' GROUP BY test" + ) + } + def instrumented_stages(self) -> set[str]: """Stage names the DB has data for — the stages coverage may narrow.""" return {stage for stage, _ in map(split_stage, self.known_tests()) if stage} @@ -200,7 +190,7 @@ def files_touched_by(self, test: str) -> list[tuple[str, str]]: for row in self._conn.execute("SELECT file, qualname FROM touch WHERE test=?", (test,)) ] - # -- coverage-completeness heuristic (interim, until the producer signals it) -- + # -- coverage-completeness heuristic -- def untrusted_tests( self, @@ -211,12 +201,10 @@ def untrusted_tests( ) -> set[str]: """Stage-prefixed tests whose per-test capture looks incomplete (must always run). - Untrusted when the test drove model execution/serving but is missing the - executor `worker_file` (its worker/server process was not captured) — - identified either by entering a `launch_markers` `(file, qualname_substring)` - (call-based) or by a `serving_path_markers` nodeid substring (path-based, - for disagg) — OR when it entered fewer than `min_funcs` functions total - (a near-empty capture, last resort). + Flags a test that drove execution/serving but is missing `worker_file` — + matched by a `launch_markers` `(file, qualname_substring)` call or a + `serving_path_markers` nodeid substring — or that entered fewer than + `min_funcs` functions total. """ drove_execution: set[str] = set() for file, qual_substr in launch_markers: diff --git a/jenkins/scripts/cbts/tools/coverage_audit.py b/jenkins/scripts/cbts/tools/coverage_audit.py index 161b30711104..b9e2309b6152 100644 --- a/jenkins/scripts/cbts/tools/coverage_audit.py +++ b/jenkins/scripts/cbts/tools/coverage_audit.py @@ -14,11 +14,9 @@ # limitations under the License. r"""Audit a CBTS touch DB (`cbts_touchmap.sqlite`) — format, scale, and coverage completeness. -Standalone: pull the sqlite locally and run this to see whether the data is -trustworthy before relying on it for selection. Reports the format (stage -prefix, schema_version, collection commit), scale, per-stage known counts, the -per-test footprint distribution, and the tests whose capture looks incomplete -(same heuristic the selector uses, so the "untrusted" set matches). +Reports the format (stage prefix, schema_version, collection commit), scale, +per-stage known counts, the per-test footprint distribution, and the tests +whose capture looks incomplete (the same heuristic the selector uses). Example:: @@ -81,10 +79,7 @@ def main(argv: list[str] | None = None) -> int: db = TouchDB.open(args.db) known = db.known_tests() stages = db.known_by_stage() - footprint = { - row[0]: row[1] - for row in db._conn.execute("SELECT test, COUNT(*) FROM touch WHERE test!='' GROUP BY test") - } + footprint = db.per_test_footprint() print(f"=== CBTS coverage DB audit: {args.db} ===\n") @@ -122,20 +117,22 @@ def main(argv: list[str] | None = None) -> int: untrusted = db.untrusted_tests( _WORKER_SENTINEL, _LAUNCH_MARKERS, _SERVING_PATH_MARKERS, args.min_funcs ) - worker = db.tests_touching_file(_WORKER_SENTINEL) def reason(test: str) -> str: if any(m in test for m in _SERVING_PATH_MARKERS): return "disagg-path (servers uninstrumented)" - if test not in worker: - return "worker-lost (drove inference, no py_executor)" - return f"near-empty (<{args.min_funcs} funcs)" + if footprint[test] < args.min_funcs: + return f"near-empty (<{args.min_funcs} funcs)" + return "worker-lost (drove inference, no py_executor)" print("\n## Coverage completeness") - print( - f" per-test footprint (functions entered): min={min(footprint.values())} " - f"max={max(footprint.values())} (few funcs => likely lost subprocess capture)" - ) + if footprint: + print( + f" per-test footprint (functions entered): min={min(footprint.values())} " + f"max={max(footprint.values())} (few funcs => likely lost subprocess capture)" + ) + else: + print(" per-test footprint: none (no test != '' rows — no usable per-test coverage)") trusted_fp = [footprint[t] for t in known if t not in untrusted] untrusted_fp = [footprint[t] for t in untrusted] if trusted_fp and untrusted_fp: @@ -163,11 +160,7 @@ def reason(test: str) -> str: for t in sorted(untrusted): print(f" [{footprint[t]:>5} funcs] {t}\n -> {reason(t)}") - # -- HEAD coverage gap on instrumented stages -- - # Cases that render on a stage the DB *does* cover, yet have no DB row (a new/ - # renamed test, or one the producer never captured) -> the selector can never - # skip them. Cases only on non-instrumented stages (disagg, multi-GPU) are - # excluded: coverage never narrows those, so their absence is expected. + # -- HEAD coverage gap: cases on an instrumented stage with no DB row -- if args.test_db and Path(args.test_db).is_dir() and Path(args.groovy).is_file(): yaml_index = YAMLIndex.load(Path(args.test_db)) all_stages = parse_stages_from_groovy(Path(args.groovy), include_post_merge=True) From e983a321ca6d3e6c264b31a55947d384e7671394 Mon Sep 17 00:00:00 2001 From: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:54:09 +0800 Subject: [PATCH 3/5] remove unused content Signed-off-by: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> --- .../coverage_selection/TOUCH_DB_CONTRACT.md | 196 ------------------ .../cbts/coverage_selection/artifact.py | 2 +- 2 files changed, 1 insertion(+), 197 deletions(-) delete mode 100644 jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md diff --git a/jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md b/jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md deleted file mode 100644 index 78fe7f53d212..000000000000 --- a/jenkins/scripts/cbts/coverage_selection/TOUCH_DB_CONTRACT.md +++ /dev/null @@ -1,196 +0,0 @@ -# CBTS Touch DB — Interface Contract (coverage-based test selection) - -Contract between the **coverage producer** (`cbts-coverage-utils` branch: -`jenkins/scripts/cbts/coverage_utils/`) and the **coverage-based selector** developed here. -The selector depends only on this contract, not on the producer's code, so the two branches -can land independently. - -Source of truth for every claim below (producer, `cbts-coverage-utils` branch): -- `coverage_utils/pystart_report.py` — builds the merged touch DB. -- `coverage_utils/cbts_pystart.py` — per-process `sys.monitoring` PY_START tracker. - ---- - -## 1. The artifact the selector consumes - -- **File:** `cbts_touchmap.sqlite` — the **merged, deduped, indexed** touch DB - (`pystart_report.py --out-sqlite`, described there as "indexed touch(test,file,qualname) DB for the selector"). -- **Packaging / retrieval:** uploaded per post-merge run as - `…///cbts-coverage/cbts_pystart_report.tar.gz`, which contains - `cbts_touchmap.sqlite` + `cbts_report/`. Extract the `.sqlite`; open **read-only**. -- **Do NOT consume** the per-process `.cbtscov...pid.X.sqlite` files: - they carry **raw absolute paths** and are **not deduped**. Only the merged DB is canonicalized - and indexed. (Per-process schema is `touch(test, file, qualname)` with no constraints.) - ---- - -## 2. Schema (merged `cbts_touchmap.sqlite`) - -```sql -CREATE TABLE touch ( - test TEXT, -- pytest nodeid that entered the function ('' == import-time / no test context) - file TEXT, -- product-relative path, canonicalized to 'tensorrt_llm/...' - qualname TEXT, -- co_qualname of the entered function/method (see §4) - UNIQUE(test, file, qualname) -- rows are deduped; no frequency/count is available -); -CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); - -CREATE INDEX ix_file ON touch(file); -- file -> tests -CREATE INDEX ix_func ON touch(file, qualname); -- (file, qualname) -> tests -CREATE INDEX ix_test ON touch(test); -- test -> touched -``` - -A row `(test, file, qualname)` means: **during an instrumented run, test `test` called the -function `qualname` defined in `file`.** PY_START fires on function *entry* (call), not on -line execution or import. - ---- - -## 3. Path normalization (`file`) — the selector MUST replicate this - -`file` in the merged DB is the producer's `canon()` of the absolute `co_filename`: - -```python -import re -def canon(path): - m = re.search(r"(tensorrt_llm/.*)$", path) - return m.group(1) if m else path -``` - -So values look like `tensorrt_llm/_torch/pyexecutor/py_executor.py`. - -**Join rule:** before querying, canonicalize the changed-file paths the *same* way. -Git-relative paths that already start with `tensorrt_llm/` are already canonical. -A changed path with no `tensorrt_llm/` segment (C++, configs, tests, tools) can **never** -match a `touch.file` — see the fail-safe rule in §7. - ---- - -## 4. `qualname` semantics - -`qualname == code.co_qualname`, recorded only when it passes the producer filter -(`cbts_pystart.py`): - -- **Excluded:** any name containing `` (nested/closure functions), and - `{, , , }` (dict/list/set comps + genexprs + lambdas). -- **Included forms:** `foo` (module-level), `Bar.baz` (method), `Outer.Inner.m` - (method of a nested class), and `` (module-body execution). - Control-flow blocks (`if`/`try`/`with`) do **not** appear in the name; class scopes prepend - `ClassName.`; crossing a function scope injects `..` (hence excluded). - -Granularity guidance: -- **File-level selection** (`WHERE file = ?`) is the robust default. -- **Function-level selection** (`WHERE file = ? AND qualname = ?`) is *best-effort*: closures, - comprehensions and lambdas are invisible, so a change confined to those maps only at file level. - ---- - -## 5. `meta` table (advisory stats, not required for selection) - -Keys currently written by `pystart_report.py`: - -| key | meaning | -|-----|---------| -| `tests` | distinct `test` where `test != ''` | -| `files` | distinct `file` where `test != ''` | -| `functions` | distinct `(file, qualname)` where `test != ''` | -| `file_rate_pct`, `func_rate_pct` | coverage rate vs `--source-root` denominator (only if that arg was passed) | -| `total_files`, `total_functions` | denominator sizes (only if `--source-root` was passed) | - -All values are **strings**. Treat every key as **optional** (read with a default) — the rate -keys are absent when the report is generated without `--source-root`. -There is **no `schema_version` key yet**; see §8. - ---- - -## 6. Consumer query patterns - -```sql --- Reverse lookup — the core of selection. Always filter test != ''. -SELECT DISTINCT test FROM touch WHERE file = :file AND test != ''; -- file -> tests -SELECT DISTINCT test FROM touch WHERE file = :file AND qualname = :q AND test != ''; -- func -> tests - --- Forward (debug / explain-why): -SELECT file, qualname FROM touch WHERE test = :test; - --- Universe of tests that have coverage data at all: -SELECT DISTINCT test FROM touch WHERE test != ''; -``` - -**Always append `test != ''`.** Rows with `test == ''` are import-time / no-context -attributions (module bodies loaded before any test), not per-test signal. - ---- - -## 7. Selection algorithm contract - -Input: set of changed `(file[, qualname])` from `git diff` (+ AST for function granularity). -Output: set of pytest nodeids to run. - -``` -selected = ∅ -for each changed product file f (canonicalized to tensorrt_llm/...): - selected ∪= { test : (test, f, *) in touch, test != '' } # file-level, safe default -return selected -``` - -**Fail-safe (correctness > savings — an undercount silently drops tests → escapes):** -- A changed path **not** under `tensorrt_llm/` (C++/CUDA, YAML, tests, tooling, build) has **no** - Python coverage → **cannot be decided by this DB** → fall back to "run" (defer to the - rules-based selector / full set). Never treat "no match" as "skip". -- A changed product file with **zero** `touch` rows → treat as **unknown → run**, not "untested → skip" - (it may only be instrumented in a stage this DB didn't cover — see §9). -- A renamed/moved function will not match its new `qualname`/`file` → treat rename as "run". - -The DB tells you which tests to **keep**; it is not authoritative about which to **drop**. - ---- - -## 8. Versioning & stability - -- The schema above is the v1 contract. Consumers should be tolerant: **select named columns** - (`SELECT test, file, qualname …`), never `SELECT *`; read `meta` keys with defaults. -- **Recommended producer addition (not yet present):** a `meta` row - `('schema_version', '1')` so the selector can hard-fail on an unknown version instead of - silently mis-selecting. Track this as a producer-side follow-up on `cbts-coverage-utils`. - ---- - -## 9. Coverage scope & guarantees (read before trusting "no test hit this") - -**Guarantee:** if test `T` entered function `F` (`file`, `qualname`) during an instrumented run -and the process's periodic/atexit save succeeded, then `(T, canon(file), qualname)` is present. - -**Non-guarantees (all imply fail-safe → run):** -- **Instrumentation is gated** (`L0_Test.groovy::isCbtsStage`, Phase 1): only **single-GPU**, - **non-Perf / non-TensorRT / non-CPP / non-AutoDeploy**, **post-merge** stages are instrumented. - Any test outside that set has **no** coverage data here. -- **Call-based, not import-based:** functions imported but never called are absent; module-level - side-effect code is attributed to `test == ''` (import time), not to a specific test. -- **Closures / comprehensions / lambdas** are not recorded (§4). -- **No C++/CUDA coverage** at all — those changes are out of scope for this DB. -- **Dedup ⇒ no counts:** you cannot rank tests by hit frequency from this DB. -- **Staleness:** the DB reflects the code at collection time; drift (renames, new functions) - is invisible until recollected. - ---- - -## 10. Local fixture for developing against this contract - -Until the producer lands in `main`, build a fixture DB with the exact schema above: - -```sql -CREATE TABLE touch (test TEXT, file TEXT, qualname TEXT, UNIQUE(test, file, qualname)); -CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); -CREATE INDEX ix_file ON touch(file); -CREATE INDEX ix_func ON touch(file, qualname); -CREATE INDEX ix_test ON touch(test); -INSERT OR IGNORE INTO touch VALUES - ('accuracy/test_llm_api_pytorch.py::TestLlama3_1_8B::test_nvfp4', - 'tensorrt_llm/_torch/pyexecutor/py_executor.py', 'PyExecutor._forward_step'), - ('accuracy/test_llm_api_pytorch.py::TestLlama3_1_8B::test_nvfp4', - 'tensorrt_llm/_torch/pyexecutor/py_executor.py', ''); -INSERT OR REPLACE INTO meta VALUES ('tests','1'),('files','1'),('functions','1'); -``` - -Wire the selector to open this read-only and exercise the §6 queries + §7 fail-safe paths. diff --git a/jenkins/scripts/cbts/coverage_selection/artifact.py b/jenkins/scripts/cbts/coverage_selection/artifact.py index be5fd585ecb3..4977c1ba1f10 100644 --- a/jenkins/scripts/cbts/coverage_selection/artifact.py +++ b/jenkins/scripts/cbts/coverage_selection/artifact.py @@ -46,7 +46,7 @@ _URM = "https://urm.nvidia.com/artifactory" _JENKINS_BASE = "https://prod.blsm.nvidia.com/sw-tensorrt-top-1/job/LLM/job/main/job/L0_PostMerge" # Max builds to walk back when recent builds have no tarball. -_MAX_PROBE = 50 +_MAX_PROBE = 10 # Per-request timeout in seconds. _TIMEOUT = 15 From 48f7609fbe5f7c662089670bbd085e38b4ee319c Mon Sep 17 00:00:00 2001 From: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:52:10 +0800 Subject: [PATCH 4/5] [None][infra] CBTS: fix coverage-audit DB path in shadow step _cbtsCoverageAudit ran coverage_audit.py with `cd ${LLM_ROOT}` while passing `--db ${covDir}/...`, but covDir already includes ${LLM_ROOT}, so the path resolved to ${LLM_ROOT}/${LLM_ROOT}/cbts_cov/... and sqlite3 could not open the DB (OperationalError: unable to open database file). Run the script by its ${LLM_ROOT}-prefixed path without cd, matching the workspace-relative mkdir/wget/tar steps in the same helper. Signed-off-by: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> --- jenkins/L0_MergeRequest.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jenkins/L0_MergeRequest.groovy b/jenkins/L0_MergeRequest.groovy index 6d936fad7a96..962a87152339 100644 --- a/jenkins/L0_MergeRequest.groovy +++ b/jenkins/L0_MergeRequest.groovy @@ -863,7 +863,7 @@ def _cbtsCoverageAudit(pipeline) trtllm_utils.llmExecStepWithRetry(pipeline, script: "wget -nv '${url}' -O ${covDir}/cbts_pystart_report.tar.gz && " + "tar xzf ${covDir}/cbts_pystart_report.tar.gz -C ${covDir}") - sh "cd ${LLM_ROOT} && python3 jenkins/scripts/cbts/tools/coverage_audit.py " + + sh "python3 ${LLM_ROOT}/jenkins/scripts/cbts/tools/coverage_audit.py " + "--db ${covDir}/cbts_touchmap.sqlite" } catch (InterruptedException e) { throw e From 09efe3498faee936a530baf9ffeaa823d6f5d2b8 Mon Sep 17 00:00:00 2001 From: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:25:12 +0800 Subject: [PATCH 5/5] [None][infra] CBTS: narrow artifact.py exception handlers Replace the broad `except Exception` (BLE001-suppressed) in _get, _exists, and fetch_latest_touch_db with `except OSError`, which covers the expected network and filesystem failures (URLError/HTTPError, timeout, SSL, connection reset, BadGzipFile) while letting genuine programming errors propagate. Drops the three noqa suppressions. Signed-off-by: Ivy Zhang <25222398+crazydemo@users.noreply.github.com> --- jenkins/scripts/cbts/coverage_selection/artifact.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jenkins/scripts/cbts/coverage_selection/artifact.py b/jenkins/scripts/cbts/coverage_selection/artifact.py index 4977c1ba1f10..74bb0159fffb 100644 --- a/jenkins/scripts/cbts/coverage_selection/artifact.py +++ b/jenkins/scripts/cbts/coverage_selection/artifact.py @@ -57,7 +57,7 @@ def _get(url: str) -> tuple[Optional[int], Optional[bytes]]: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, None - except Exception as e: # noqa: BLE001 + except OSError as e: print(f"[artifact] error fetching {url}: {e}", file=sys.stderr) return None, None @@ -70,7 +70,7 @@ def _exists(url: str) -> bool: return resp.status in (200, 206) except urllib.error.HTTPError: return False - except Exception as e: # noqa: BLE001 + except OSError as e: print(f"[artifact] error probing {url}: {e}", file=sys.stderr) return False @@ -140,7 +140,7 @@ def fetch_latest_touch_db(dest_dir: Path | str, url: Optional[str] = None) -> Op with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp, open(tarball, "wb") as f: shutil.copyfileobj(resp, f) return extract_touch_db(tarball, dest_dir) - except Exception as e: # noqa: BLE001 + except OSError as e: print(f"[artifact] download/extract failed {url}: {e}", file=sys.stderr) return None