| 1 | """Repository tiers and the 14-shape core matrix as runnable fixture recipes.
|
| 2 |
|
| 3 | Tiers re-implement the *concept* of git's t/perf repository tiers (the
|
| 4 | ``test_perf_fresh_repo`` / ``test_perf_default_repo`` / ``test_perf_large_repo``
|
| 5 | helpers in t/perf/perf-lib.sh, GPL-2.0: concept only, no text or code) with
|
| 6 | deterministic synthetic repositories instead of copies of git.git/linux:
|
| 7 |
|
| 8 | - ``fresh``: fixed-overhead tier; a just-created repository with one commit;
|
| 9 | - ``default``: an application-sized repository (git.git-ish order of files,
|
| 10 | modest history, some refs);
|
| 11 | - ``large``: monorepo pressure (100k files, 10k commits, 1k branches, a
|
| 12 | 256 MiB binary with revisions);
|
| 13 | - ``xl-*``: one axis pushed past real-world "large" per fixture, opt-in with
|
| 14 | ``--allow-xl`` (history 100k commits, tree 1M files, deep 40-level paths,
|
| 15 | refs 10k branches + 2k tags, a multi-GiB binary). ``xl-combined`` puts every
|
| 16 | axis in one repository and additionally needs ``--allow-xl-combined``.
|
| 17 |
|
| 18 | Shapes: every scenario of scenarios/core.yaml (the 14-shape catalog that no
|
| 19 | lane ran) maps to a GenSpec via its ``fixture_shape`` (text/binary counts and
|
| 20 | sizes, path depth and fan-out, history, branches, ignore patterns, conflict
|
| 21 | shape, dirty shape). ``scale`` shrinks counts (never depth) for development
|
| 22 | runs; the scale factor is part of the fixture id and spec hash.
|
| 23 | """
|
| 24 |
|
| 25 | from __future__ import annotations
|
| 26 |
|
| 27 | import math
|
| 28 | from dataclasses import dataclass, field, replace
|
| 29 | from pathlib import Path
|
| 30 | from typing import Any, Dict, List, Optional, Tuple
|
| 31 |
|
| 32 | from .generator import GenSpec
|
| 33 |
|
| 34 | ROOT = Path(__file__).resolve().parents[3]
|
| 35 | CORE_YAML = ROOT / "scenarios" / "core.yaml"
|
| 36 | GIB = 1 << 30
|
| 37 | MIB = 1 << 20
|
| 38 |
|
| 39 |
|
| 40 | @dataclass(frozen=True)
|
| 41 | class DirtyShape:
|
| 42 | modified: int
|
| 43 | added: int
|
| 44 | deleted: int
|
| 45 | renamed: int
|
| 46 |
|
| 47 |
|
| 48 | @dataclass(frozen=True)
|
| 49 | class Tier:
|
| 50 | tier_id: str
|
| 51 | kind: str # "tier" | "xl" | "shape"
|
| 52 | spec: GenSpec
|
| 53 | dirty: DirtyShape
|
| 54 | ignored_extras: int = 0
|
| 55 | xl: bool = False
|
| 56 | combined: bool = False
|
| 57 | # Operations that are too costly per run at this size (or meaningless for
|
| 58 | # the shape) are listed here and emitted as explicit skip rows.
|
| 59 | skip_ops: Tuple[str, ...] = ()
|
| 60 | runs_hint: int = 5
|
| 61 | # False: no sync clients/servers (disk budget, e.g. multi-GiB blobs x 4
|
| 62 | # checkouts); push/pull/clone become skip rows naming the reason.
|
| 63 | sync: bool = True
|
| 64 | notes: str = ""
|
| 65 |
|
| 66 | @property
|
| 67 | def fixture_id(self) -> str:
|
| 68 | return f"scale-{self.tier_id}-{self.spec.spec_sha256()[:12]}"
|
| 69 |
|
| 70 |
|
| 71 | def default_dirty(files: int) -> DirtyShape:
|
| 72 | return DirtyShape(modified=max(1, files // 100), added=max(1, files // 400),
|
| 73 | deleted=max(1, files // 1000), renamed=max(1, files // 1000))
|
| 74 |
|
| 75 |
|
| 76 | def _tier(tier_id: str, kind: str, spec: GenSpec, **kwargs: Any) -> Tier:
|
| 77 | return Tier(tier_id=tier_id, kind=kind, spec=spec, dirty=kwargs.pop("dirty", default_dirty(spec.files)), **kwargs)
|
| 78 |
|
| 79 |
|
| 80 | # Per-run clone/gc of a 1M-file or multi-GiB fixture costs tens of minutes on
|
| 81 | # the dev host; XL axes time them in the calibrated campaign only.
|
| 82 | XL_HEAVY = ("clone.full", "gc")
|
| 83 |
|
| 84 | TIERS: Dict[str, Tier] = {
|
| 85 | "fresh": _tier("fresh", "tier", GenSpec(name="fresh", seed="oakbench-scale-fresh-v1", files=100,
|
| 86 | file_bytes=512, depth=3, fanout=16, commits=1, branches=1,
|
| 87 | branch_commit_files=2)),
|
| 88 | "default": _tier("default", "tier", GenSpec(name="default", seed="oakbench-scale-default-v1", files=5000,
|
| 89 | file_bytes=1024, depth=6, fanout=16, commits=1000,
|
| 90 | touches_per_commit=5, branches=50, tags=20)),
|
| 91 | "large": _tier("large", "tier", GenSpec(name="large", seed="oakbench-scale-large-v1", files=100_000,
|
| 92 | file_bytes=512, depth=8, fanout=64, commits=10_000,
|
| 93 | touches_per_commit=8, branches=1000, tags=200, binaries=1,
|
| 94 | binary_bytes=256 * MIB, binary_revisions=3)),
|
| 95 | "xl-history": _tier("xl-history", "xl", GenSpec(name="xl-history", seed="oakbench-scale-xl-history-v1",
|
| 96 | files=10_000, file_bytes=256, depth=5, fanout=32,
|
| 97 | commits=100_000, touches_per_commit=5, branches=1),
|
| 98 | xl=True, skip_ops=XL_HEAVY),
|
| 99 | "xl-tree": _tier("xl-tree", "xl", GenSpec(name="xl-tree", seed="oakbench-scale-xl-tree-v1", files=1_000_000,
|
| 100 | file_bytes=64, depth=6, fanout=100, commits=3, branches=1),
|
| 101 | dirty=DirtyShape(modified=1000, added=100, deleted=100, renamed=100), xl=True,
|
| 102 | skip_ops=XL_HEAVY),
|
| 103 | "xl-deep": _tier("xl-deep", "xl", GenSpec(name="xl-deep", seed="oakbench-scale-xl-deep-v1", files=50_000,
|
| 104 | file_bytes=256, depth=40, fanout=8, commits=100, branches=1),
|
| 105 | xl=True),
|
| 106 | "xl-refs": _tier("xl-refs", "xl", GenSpec(name="xl-refs", seed="oakbench-scale-xl-refs-v1", files=2000,
|
| 107 | file_bytes=512, depth=4, fanout=16, commits=1000,
|
| 108 | branches=10_000, tags=2000), xl=True),
|
| 109 | "xl-bigblob": _tier("xl-bigblob", "xl", GenSpec(name="xl-bigblob", seed="oakbench-scale-xl-bigblob-v1",
|
| 110 | files=16, file_bytes=512, depth=2, fanout=16, commits=3,
|
| 111 | binaries=1, binary_bytes=3 * GIB, binary_revisions=2,
|
| 112 | branches=1),
|
| 113 | xl=True, skip_ops=XL_HEAVY, sync=False),
|
| 114 | "xl-combined": _tier("xl-combined", "xl", GenSpec(name="xl-combined", seed="oakbench-scale-xl-combined-v1",
|
| 115 | files=1_000_000, file_bytes=64, depth=32, fanout=100,
|
| 116 | commits=100_000, touches_per_commit=5, branches=10_000,
|
| 117 | tags=2000, binaries=1, binary_bytes=3 * GIB,
|
| 118 | binary_revisions=2),
|
| 119 | dirty=DirtyShape(modified=1000, added=100, deleted=100, renamed=100), xl=True,
|
| 120 | combined=True, skip_ops=XL_HEAVY),
|
| 121 | }
|
| 122 |
|
| 123 | TIER_ORDER = ("fresh", "default", "large", "xl-history", "xl-tree", "xl-deep", "xl-refs", "xl-bigblob")
|
| 124 |
|
| 125 |
|
| 126 | # ---------------------------------------------------------------------------
|
| 127 | # The 14 core.yaml shapes
|
| 128 | # ---------------------------------------------------------------------------
|
| 129 |
|
| 130 |
|
| 131 | def load_core_shapes(path: Path = CORE_YAML) -> List[Dict[str, Any]]:
|
| 132 | import sys
|
| 133 | scripts = str(ROOT / "scripts")
|
| 134 | if scripts not in sys.path:
|
| 135 | sys.path.insert(0, scripts)
|
| 136 | from mount_probe import parse_yaml_subset # the lanes' shared YAML-subset reader
|
| 137 | document = parse_yaml_subset(path.read_text())
|
| 138 | return list(document["scenarios"])
|
| 139 |
|
| 140 |
|
| 141 | def _scaled(value: int, scale: float, minimum: int = 1) -> int:
|
| 142 | return max(minimum, int(math.ceil(value * scale))) if value else 0
|
| 143 |
|
| 144 |
|
| 145 | def shape_tier(shape: Dict[str, Any], scale: float = 1.0) -> Tier:
|
| 146 | """A runnable Tier for one core.yaml scenario at ``scale``."""
|
| 147 | if not (0 < scale <= 1.0):
|
| 148 | raise ValueError("scale must be in (0, 1]")
|
| 149 | fs = shape["fixture_shape"]
|
| 150 | text = fs.get("text_files") or {}
|
| 151 | binary = fs.get("binary_files") or {}
|
| 152 | text_count = int(text.get("count") or 0)
|
| 153 | files = _scaled(max(1, text_count), scale)
|
| 154 | binaries = _scaled(int(binary.get("count") or 0), scale) if binary.get("count") else 0
|
| 155 | binary_bytes = int(binary.get("size_bytes") or 0)
|
| 156 | if binary_bytes and scale < 1.0:
|
| 157 | binary_bytes = max(MIB, int(binary_bytes * scale))
|
| 158 | commits = _scaled(int(fs.get("history_commits") or 1), scale)
|
| 159 | # at least one side branch so the switch/review/merge ops have a target
|
| 160 | branches = max(1, _scaled(int(fs.get("branches") or 1), scale) - 1)
|
| 161 | ignore = tuple(fs.get("ignore_patterns") or ())
|
| 162 | ignored = _scaled(int(fs.get("ignored_files") or 0), scale, minimum=0)
|
| 163 | if ignored and not ignore:
|
| 164 | ignore = ("build/",)
|
| 165 | conflict = fs.get("conflict_shape") or {}
|
| 166 | conflict_files = min(files, _scaled(int(conflict.get("conflicting_files") or 0), scale, minimum=0))
|
| 167 | binary_revisions = 0
|
| 168 | if binaries and shape["id"] == "binary-history":
|
| 169 | binary_revisions = commits
|
| 170 | dirty_raw = fs.get("dirty_shape") or {}
|
| 171 | dirty = DirtyShape(
|
| 172 | modified=max(1, _scaled(int(dirty_raw.get("modified_files") or 1), scale)),
|
| 173 | added=max(1, _scaled(int(dirty_raw.get("added_files") or 1), scale)),
|
| 174 | deleted=_scaled(int(dirty_raw.get("deleted_files") or 0), scale, minimum=0),
|
| 175 | renamed=_scaled(int(dirty_raw.get("renamed_files") or 0), scale, minimum=0),
|
| 176 | )
|
| 177 | # Dirty edits never exceed a quarter of the tree (the rest is protected
|
| 178 | # probe/branch/conflict paths and later runs' pools).
|
| 179 | cap = max(1, files // 4)
|
| 180 | dirty = DirtyShape(*(min(cap, value) for value in (dirty.modified, dirty.added, dirty.deleted, dirty.renamed)))
|
| 181 | spec = GenSpec(
|
| 182 | name=f"shape-{shape['id']}", seed=f"oakbench-core-shape-{shape['id']}-v1-x{scale:g}", files=files,
|
| 183 | file_bytes=max(32, int(text.get("size_bytes") or 512)), depth=max(1, int(fs.get("max_path_depth") or 2)),
|
| 184 | fanout=max(2, int(fs.get("path_fanout") or 16)), commits=commits,
|
| 185 | touches_per_commit=max(1, min(10, files // 10 or 1)), branches=branches,
|
| 186 | branch_commit_files=min(files, int((fs.get("branch_mutations") or {}).get("files_modified_per_branch") or 2)),
|
| 187 | binaries=binaries, binary_bytes=binary_bytes if binaries else 0, binary_revisions=binary_revisions,
|
| 188 | ignore_patterns=ignore, conflict_files=conflict_files,
|
| 189 | notes=f"core.yaml scenario {shape['id']} at scale {scale:g}",
|
| 190 | )
|
| 191 | return Tier(tier_id=f"shape-{shape['id']}-x{scale:g}", kind="shape", spec=spec, dirty=dirty,
|
| 192 | ignored_extras=ignored, notes=str(shape.get("purpose") or ""))
|
| 193 |
|
| 194 |
|
| 195 | def core_shape_tiers(scale: float = 1.0, path: Path = CORE_YAML) -> Dict[str, Tier]:
|
| 196 | return {shape["id"]: shape_tier(shape, scale) for shape in load_core_shapes(path)}
|
| 197 |
|
| 198 |
|
| 199 | def resolve(name: str, *, shape_scale: float = 1.0, allow_xl: bool = False,
|
| 200 | allow_xl_combined: bool = False) -> Tier:
|
| 201 | """Tier by name: a tier id, or ``shape:<core.yaml id>``."""
|
| 202 | if name.startswith("shape:"):
|
| 203 | shapes = core_shape_tiers(shape_scale)
|
| 204 | key = name.split(":", 1)[1]
|
| 205 | if key not in shapes:
|
| 206 | raise SystemExit(f"unknown core.yaml shape {key!r}; known: {', '.join(sorted(shapes))}")
|
| 207 | return shapes[key]
|
| 208 | if name not in TIERS:
|
| 209 | raise SystemExit(f"unknown tier {name!r}; known: {', '.join(TIERS)} or shape:<id>")
|
| 210 | tier = TIERS[name]
|
| 211 | if tier.xl and not allow_xl:
|
| 212 | raise SystemExit(f"tier {name} is XL: pass --allow-xl (and budget the disk; see suites/scale/README.md)")
|
| 213 | if tier.combined and not allow_xl_combined:
|
| 214 | raise SystemExit("xl-combined needs --allow-xl-combined as well as --allow-xl")
|
| 215 | return tier
|