Skip to content

Commit

Permalink
Merge pull request #635 from burnpanck/feature/faster-CI
Browse files Browse the repository at this point in the history
Faster CI: Sparse run matrix, cancel previous jobs on the same branch
  • Loading branch information
mpusz authored Nov 13, 2024
2 parents 420ffef + b35e241 commit 6bcb46c
Show file tree
Hide file tree
Showing 11 changed files with 444 additions and 316 deletions.
3 changes: 3 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ ignore =
E712,
# line break before binary operator
W503
per-file-ignores =
# flake8 is just plain wrong here, contradicting black
.github/generate-job-matrix.py:E225,E231
206 changes: 206 additions & 0 deletions .github/generate-job-matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import argparse
import json
import os
import random
import typing
from types import SimpleNamespace

from job_matrix import CombinationCollector, Compiler, Configuration


def make_gcc_config(version: int) -> Configuration:
return Configuration(
name=f"GCC-{version}",
os="ubuntu-24.04",
compiler=Compiler(
type="GCC",
version=version,
cc=f"gcc-{version}",
cxx=f"g++-{version}",
),
cxx_modules=False,
std_format_support=version >= 13,
)


def make_clang_config(
version: int, platform: typing.Literal["x86-64", "arm64"] = "x86-64"
) -> Configuration:
cfg = SimpleNamespace(
name=f"Clang-{version} ({platform})",
compiler=SimpleNamespace(
type="CLANG",
version=version,
),
lib="libc++",
cxx_modules=version >= 17,
std_format_support=version >= 17,
)
match platform:
case "x86-64":
cfg.os = "ubuntu-22.04" if version < 17 else "ubuntu-24.04"
cfg.compiler.cc = f"clang-{version}"
cfg.compiler.cxx = f"clang++-{version}"
case "arm64":
cfg.os = "macos-14"
pfx = f"/opt/homebrew/opt/llvm@{version}/bin"
cfg.compiler.cc = f"{pfx}/clang"
cfg.compiler.cxx = f"{pfx}/clang++"
case _:
raise KeyError(f"Unsupported platform {platform!r} for Clang")
ret = cfg
ret.compiler = Compiler(**vars(cfg.compiler))
return Configuration(**vars(ret))


def make_apple_clang_config(version: int) -> Configuration:
ret = Configuration(
name=f"Apple Clang {version}",
os="macos-13",
compiler=Compiler(
type="APPLE_CLANG",
version=f"{version}.0",
cc="clang",
cxx="clang++",
),
cxx_modules=False,
std_format_support=False,
)
return ret


def make_msvc_config(release: str, version: int) -> Configuration:
ret = Configuration(
name=f"MSVC {release}",
os="windows-2022",
compiler=Compiler(
type="MSVC",
version=version,
cc="",
cxx="",
),
cxx_modules=False,
std_format_support=True,
)
return ret


configs = {
c.name: c
for c in [make_gcc_config(ver) for ver in [12, 13, 14]]
+ [
make_clang_config(ver, platform)
for ver in [16, 17, 18]
for platform in ["x86-64", "arm64"]
# arm64 runners are expensive; only consider one version
if ver == 18 or platform != "arm64"
]
+ [make_apple_clang_config(ver) for ver in [15]]
+ [make_msvc_config(release="14.4", version=194)]
}

full_matrix = dict(
config=list(configs.values()),
std=[20, 23],
formatting=["std::format", "fmtlib"],
contracts=["none", "gsl-lite", "ms-gsl"],
build_type=["Release", "Debug"],
)


def main():
parser = argparse.ArgumentParser()
# parser.add_argument("-I","--include",nargs="+",action="append")
# parser.add_argument("-X","--exclude",nargs="+",action="append")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--preset", default=None)
parser.add_argument("--debug", nargs="+", default=["combinations"])
parser.add_argument("--suppress-output", default=False, action="store_true")

args = parser.parse_args()

rgen = random.Random(args.seed)

collector = CombinationCollector(
full_matrix,
hard_excludes=lambda e: (
e.formatting == "std::format" and not e.config.std_format_support
),
)
match args.preset:
case None:
pass
case "all":
collector.all_combinations()
case "conan" | "cmake":
collector.all_combinations(
formatting="std::format",
contracts="gsl-lite",
build_type="Debug",
std=20,
)
collector.all_combinations(
filter=lambda me: not me.config.std_format_support,
formatting="fmtlib",
contracts="gsl-lite",
build_type="Debug",
std=20,
)
collector.sample_combinations(rgen=rgen, min_samples_per_value=2)
case "clang-tidy":
collector.all_combinations(config=configs["Clang-18 (x86-64)"])
case "freestanding":
# TODO For some reason Clang-18 Debug with -ffreestanding does not pass CMakeTestCXXCompiler
collector.all_combinations(
filter=lambda e: not (
e.config.name.startswith("Clang-18") and e.build_type == "Debug"
),
config=[configs[c] for c in ["GCC-14", "Clang-18 (x86-64)"]],
contracts="none",
std=23,
)
case _:
raise KeyError(f"Unsupported preset {args.preset!r}")

if not collector.combinations:
raise ValueError("No combination has been produced")

data = sorted(collector.combinations)

json_data = [e.as_json() for e in data]

output_file = os.environ.get("GITHUB_OUTPUT")
if not args.suppress_output:
if output_file:
print(f"Writing outputs to {output_file}")
with open(output_file, "wt") as fh:
fh.write(f"matrix={json.dumps(json_data)}")
else:
print("No output file received!")

for dbg in args.debug:
match dbg:
case "yaml":
import yaml

json_data = json.loads(json.dumps(json_data))
print(yaml.safe_dump(json_data))
case "json":
print(json.dumps(json_data, indent=4))
case "combinations":
for e in data:
print(
f"{e.config!s:17s} c++{e.std:2d} {e.formatting:11s} {e.contracts:8s} {e.build_type:8s}"
)
case "counts":
print(f"Total combinations {len(data)}")
for (k, v), n in sorted(collector.per_value_counts.items()):
print(f" {k}={v}: {n}")
case "none":
pass
case _:
raise KeyError(f"Unknown debug mode {dbg!r}")


if __name__ == "__main__":
main()
139 changes: 139 additions & 0 deletions .github/job_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import dataclasses
import itertools
import random
import typing
from dataclasses import dataclass


@dataclass(frozen=True, order=True)
class Compiler:
type: typing.Literal["GCC", "CLANG", "APPLE_CLANG", "MSVC"]
version: str | int
cc: str
cxx: str


@dataclass(frozen=True, order=True)
class Configuration:
name: str
os: str
compiler: Compiler
cxx_modules: bool
std_format_support: bool
conan_config: str = ""
lib: typing.Literal["libc++", "libstdc++"] | None = None

def __str__(self):
return self.name


@dataclass(frozen=True, order=True)
class MatrixElement:
config: Configuration
std: typing.Literal[20, 23]
formatting: typing.Literal["std::format", "fmtlib"]
contracts: typing.Literal["none", "gsl-lite", "ms-gsl"]
build_type: typing.Literal["Release", "Debug"]

def as_json(self):
def dataclass_to_json(obj):
"""Convert dataclasses to something json-serialisable"""
if dataclasses.is_dataclass(obj):
return {
k: dataclass_to_json(v) for k, v in dataclasses.asdict(obj).items()
}
return obj

ret = dataclass_to_json(self)
# patch boolean conan configuration options
config = ret["config"]
for k in ["cxx_modules"]:
config[k] = "True" if config[k] else "False"
return ret


class CombinationCollector:
"""Incremental builder of MatrixElements, allowing successive selection of entries."""

def __init__(
self,
full_matrix: dict[str, list[typing.Any]],
*,
hard_excludes: typing.Callable[[MatrixElement], bool] | None = None,
):
self.full_matrix = full_matrix
self.hard_excludes = hard_excludes
self.combinations: set[MatrixElement] = set()
self.per_value_counts: dict[tuple[str, typing.Any], int] = {
(k, v): 0 for k, options in full_matrix.items() for v in options
}

def _make_submatrix(self, **overrides):
new_matrix = dict(self.full_matrix)
for k, v in overrides.items():
if not isinstance(v, list):
v = [v]
new_matrix[k] = v
return new_matrix

def _add_combination(self, e: MatrixElement):
if e in self.combinations or (
self.hard_excludes is not None and self.hard_excludes(e)
):
return
self.combinations.add(e)
# update per_value_counts
for k, v in vars(e).items():
idx = (k, v)
self.per_value_counts[idx] = self.per_value_counts.get(idx, 0) + 1

def all_combinations(
self,
*,
filter: typing.Callable[[MatrixElement], bool] | None = None,
**overrides,
):
"""Adds all combinations in the submatrix defined by `overrides`."""
matrix = self._make_submatrix(**overrides)
keys = tuple(matrix.keys())
for combination in itertools.product(*matrix.values()):
cand = MatrixElement(**dict(zip(keys, combination)))
if filter and not filter(cand):
continue
self._add_combination(cand)

def sample_combinations(
self,
*,
rgen: random.Random,
min_samples_per_value: int = 1,
filter: typing.Callable[[MatrixElement], bool] | None = None,
**overrides,
):
"""Adds samples from the submatrix defined by `overrides`,
ensuring each individual value appears at least n times.
"""
matrix = self._make_submatrix(**overrides)
missing: dict[tuple[str, typing.Any], int] = {}
for key, options in matrix.items():
for value in options:
idx = (key, value)
missing[idx] = min_samples_per_value - self.per_value_counts.get(idx, 0)
while missing:
(force_key, force_option), remaining = next(iter(missing.items()))
if remaining <= 0:
missing.pop((force_key, force_option))
continue
choice = {}
for key, options in matrix.items():
choice[key] = force_option if key == force_key else rgen.choice(options)
cand = MatrixElement(**choice)
if filter and not filter(cand):
continue
self._add_combination(cand)
for idx in choice.items():
if missing.pop(idx, 0) <= 0:
continue
remaining = min_samples_per_value - self.per_value_counts.get(idx, 0)
if remaining > 0:
missing[idx] = remaining
Loading

0 comments on commit 6bcb46c

Please sign in to comment.