diff --git a/.gitignore b/.gitignore
index 4f82135..383ea93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
/target/
/.reports*.jsonl
+/.profiles/
/.bench-worktrees/
/.venv/
/.mypy_cache/
diff --git a/Cargo.lock b/Cargo.lock
index f66efe3..0da4312 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -97,6 +97,15 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -148,6 +157,18 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
[[package]]
name = "cc"
version = "1.2.65"
@@ -296,6 +317,38 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "columnar"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94063ab5cdb61ed8e5a8294c3c7cc8466c1a9eafc7b9ca02b2a521ea06e52d92"
+dependencies = [
+ "bytemuck",
+ "columnar_derive",
+ "smallvec",
+]
+
+[[package]]
+name = "columnar_derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d11c5dbac40214de1027e4783272d192198c986d94b65ae43c0886b4fb58ec5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "columnation"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b90b1614014f6958477dcdb77a2d489555db48ca61efe94c5cde2b0446933ed1"
+dependencies = [
+ "paste",
+ "smallvec",
+]
+
[[package]]
name = "condtype"
version = "1.3.0"
@@ -454,6 +507,21 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "differential-dataflow"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "befdc46c04f5671a2dfe32a9d06ce8efe44f650f0f6f974d190b9a6ee1c17665"
+dependencies = [
+ "columnar",
+ "columnation",
+ "fnv",
+ "paste",
+ "serde",
+ "smallvec",
+ "timely",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -507,6 +575,7 @@ dependencies = [
"dyn-clone",
"egglog-add-primitive",
"egglog-ast",
+ "egglog-backend-trait",
"egglog-bridge",
"egglog-core-relations",
"egglog-numeric-id",
@@ -549,6 +618,17 @@ dependencies = [
"ordered-float",
]
+[[package]]
+name = "egglog-backend-trait"
+version = "2.0.0"
+dependencies = [
+ "anyhow",
+ "egglog-ast",
+ "egglog-bridge",
+ "egglog-core-relations",
+ "egglog-reports",
+]
+
[[package]]
name = "egglog-bridge"
version = "2.0.0"
@@ -624,6 +704,7 @@ dependencies = [
"codspeed-divan-compat",
"egglog",
"egglog-ast",
+ "egglog-experimental-dd",
"egglog-reports",
"glob",
"insta",
@@ -633,6 +714,26 @@ dependencies = [
"num",
]
+[[package]]
+name = "egglog-experimental-dd"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "differential-dataflow",
+ "egglog",
+ "egglog-ast",
+ "egglog-backend-trait",
+ "egglog-core-relations",
+ "egglog-experimental",
+ "egglog-numeric-id",
+ "hashbrown 0.16.1",
+ "insta",
+ "libtest-mimic",
+ "log",
+ "serde",
+ "timely",
+]
+
[[package]]
name = "egglog-numeric-id"
version = "2.0.0"
@@ -840,6 +941,15 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getopts"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
+dependencies = [
+ "unicode-width",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1246,6 +1356,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
[[package]]
name = "pest"
version = "2.8.6"
@@ -1675,6 +1791,9 @@ name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
[[package]]
name = "statrs"
@@ -1766,6 +1885,62 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "timely"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5af6859fde675a87f12da672e0bdf08bfc809e68d8c6728f50942d09c0480a3"
+dependencies = [
+ "bincode",
+ "byteorder",
+ "columnar",
+ "columnation",
+ "getopts",
+ "itertools",
+ "serde",
+ "smallvec",
+ "timely_bytes",
+ "timely_communication",
+ "timely_container",
+ "timely_logging",
+]
+
+[[package]]
+name = "timely_bytes"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b427b919d60d815c9c30b52d8080ebde599380db18c7c98a8c99fc172601ca1"
+
+[[package]]
+name = "timely_communication"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2137e50f9c368c04d9971b7909c6fceadedb24c6e4fa4173bf55bf6d415329db"
+dependencies = [
+ "byteorder",
+ "columnar",
+ "getopts",
+ "serde",
+ "timely_bytes",
+ "timely_container",
+ "timely_logging",
+]
+
+[[package]]
+name = "timely_container"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b45d41809553b99db9abc2ad2c5f5b8f755df1f2191051527deaadeea12d5a7e"
+
+[[package]]
+name = "timely_logging"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00582f3cfc652e3173eb9a140981133f359029e6b746667414a8d334285163d6"
+dependencies = [
+ "timely_container",
+]
+
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -1820,6 +1995,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
[[package]]
name = "utf8parse"
version = "0.2.2"
diff --git a/Cargo.toml b/Cargo.toml
index 34895e3..ef12034 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"egglog/concurrency",
"egglog/core-relations",
"egglog/egglog-ast",
+ "egglog/egglog-backend-trait",
"egglog/egglog-bridge",
"egglog/egglog-reports",
"egglog/numeric-id",
@@ -12,6 +13,7 @@ members = [
"egglog/src/sort/add_primitive",
"egglog/wasm-example",
"egglog-experimental",
+ "egglog-experimental/dd",
]
[workspace.package]
@@ -81,6 +83,7 @@ egglog-numeric-id = { path = "egglog/numeric-id", version = "2.0.0" }
egglog-union-find = { path = "egglog/union-find", version = "2.0.0" }
egglog-add-primitive = { path = "egglog/src/sort/add_primitive", version = "2.0.0" }
egglog-reports = { path = "egglog/egglog-reports", version = "2.0.0" }
+egglog-backend-trait = { path = "egglog/egglog-backend-trait", version = "2.0.0" }
egglog = { path = "egglog", default-features = false, version = "2.0.0" }
######################
diff --git a/bench.py b/bench.py
index 93db46f..6dbbc94 100755
--- a/bench.py
+++ b/bench.py
@@ -7,11 +7,14 @@
import os
import re
import resource
+import shlex
+import shutil
import subprocess
import sys
import tempfile
import time
-from collections.abc import Mapping, Sequence
+import uuid
+from collections.abc import Callable, Mapping, Sequence
from contextlib import suppress
from dataclasses import dataclass, replace
from datetime import UTC, datetime
@@ -30,6 +33,7 @@
MofNCompleteColumn,
Progress,
SpinnerColumn,
+ TaskID,
TextColumn,
TimeElapsedColumn,
)
@@ -38,16 +42,43 @@
from rich.tree import Tree
from scipy import stats
+import samply_analysis
+
Status = Literal["success", "timed-out", "failure"]
+Backend = str
Treatment = Literal["off", "term", "proofs"]
+BuildProfile = Literal["release", "profiling"]
+OutputFormat = Literal["rich", "markdown"]
+TableAlignment = Literal["left", "right"]
+
+
+@dataclass(frozen=True)
+class BackendSpec:
+ display_name: str
+ treatments: tuple[Treatment, ...]
+ flags: tuple[str, ...]
+ cargo_features: tuple[str, ...] = ()
+
+
+BACKEND_SPECS: dict[Backend, BackendSpec] = {
+ "main": BackendSpec("main", ("off", "term", "proofs"), ()),
+ "dd": BackendSpec("DD", ("term", "proofs"), ("--backend", "dd"), ("dd-backend",)),
+}
DEFAULT_REPORT = ".reports.jsonl"
+DEFAULT_PROFILES_DIR = ".profiles"
DEFAULT_ROUNDS = 6
DEFAULT_TIMEOUT_SEC = 120
+DEFAULT_PROFILE_SECONDS = 10
+DEFAULT_PROFILE_TOP = 15
+MAX_PROFILE_ITERATIONS = 10_000
+PROFILE_CACHE_VERSION = "v1"
+PROFILE_SAMPLY_RATE_HZ = 1000
TARGET_STARTUP_WARMUP_SUBPROCESSES = 1
+DEFAULT_BACKENDS: tuple[Backend, ...] = ("main",)
DEFAULT_TREATMENTS: tuple[Treatment, ...] = ("off", "term", "proofs")
DEFAULT_FILES = (
- "egglog/tests/math-microbenchmark.egg",
+ "egglog/tests/math-microbenchmark-mini.egg",
"egglog/tests/web-demo/rw-analysis.egg",
"egglog/tests/integer_math.egg",
"egglog/tests/web-demo/resolution.egg",
@@ -61,7 +92,15 @@
"Ratio is target / baseline. Values below 1x use less peak RSS; above 1x use more. "
"RSS change is derived from the ratio; negative uses less memory. Intervals are 95% CIs."
)
-PROOF_OVERHEAD_CAPTION = "Within-target proof overhead. This is separate from target-vs-baseline wall-time change."
+BACKEND_WALL_TIME_CAPTION = (
+ "Ratio is candidate backend / baseline backend for the same target and treatment. "
+ "Values below 1x are faster; above 1x are slower. Intervals are 95% CIs."
+)
+BACKEND_PEAK_RSS_CAPTION = (
+ "Ratio is candidate backend / baseline backend for the same target and treatment. "
+ "Values below 1x use less peak RSS; above 1x use more. Intervals are 95% CIs."
+)
+PROOF_OVERHEAD_CAPTION = "Within-backend proof overhead. This is not backend-vs-main performance."
RESULT_STYLES = {
"descriptive": "dim",
"established": "green",
@@ -119,6 +158,7 @@ class Config:
binary_sha256: Series[str]
file_path: Series[str]
file_sha256: Series[str]
+ backend: Series[str] = pa.Field(isin=list(BACKEND_SPECS))
treatment: Series[str] = pa.Field(isin=["off", "term", "proofs"])
timeout_sec: Series[int] = pa.Field(gt=0)
wall_sec: Series[float] = pa.Field(nullable=True, ge=0)
@@ -161,6 +201,42 @@ class BenchmarkSpec:
treatments: tuple[Treatment, ...]
rounds: int
timeout_sec: int
+ backends: tuple[Backend, ...] = DEFAULT_BACKENDS
+
+
+@dataclass(frozen=True)
+class BenchmarkCell:
+ backend: Backend
+ treatment: Treatment
+
+
+@dataclass(frozen=True)
+class ProfileMode:
+ iterations: int | None
+ profile_seconds: int | None
+
+ @property
+ def cache_label(self) -> str:
+ if self.iterations is not None:
+ return f"i{self.iterations}"
+ assert self.profile_seconds is not None
+ return f"auto{self.profile_seconds}s"
+
+
+@dataclass(frozen=True)
+class ProfileRequest:
+ file: FileSpec
+ target_request: TargetRequest
+ backend: Backend
+ treatment: Treatment
+ timeout_sec: int
+ profiles_dir: Path
+ mode: ProfileMode
+ open_after: bool
+ force_run: bool
+ top: int = DEFAULT_PROFILE_TOP
+ show_summary: bool = True
+ output_format: OutputFormat = "rich"
@dataclass(frozen=True)
@@ -215,12 +291,14 @@ class EstimateKey:
file_sha256: str
treatment: Treatment
timeout_sec: int
+ backend: Backend = "main"
@dataclass(frozen=True)
class CellPlan:
target: ResolvedTarget
file: FileSpec
+ backend: Backend
treatment: Treatment
required_rows: int
selected_cached_rows: DataFrame[ReportFrame]
@@ -270,7 +348,7 @@ def ok(self) -> bool:
return self.issue is None and self.mean is not None
-CellMap = dict[tuple[str, Treatment], CellSummary]
+CellMap = dict[tuple[str, Backend, Treatment], CellSummary]
TargetCellMaps = dict[ResolvedTarget, CellMap]
@@ -292,7 +370,33 @@ def ok(self) -> bool:
return self.issue is None and self.point is not None
+@dataclass(frozen=True)
+class ReportTableData:
+ title: str
+ headers: tuple[str, ...]
+ rows: tuple[tuple[str, ...], ...]
+ caption: str | None = None
+ alignments: tuple[TableAlignment, ...] | None = None
+
+
+@dataclass(frozen=True)
+class MetricSpec:
+ title: str
+ caption: str
+ file_count_heading: str
+ format_total: Callable[[float | None], str]
+ format_change: Callable[[RatioSummary], str]
+ format_result: Callable[[RatioSummary], str]
+ omit_without_samples: bool = False
+
+
def parse_args(argv: Sequence[str]) -> argparse.Namespace:
+ if argv and argv[0] == "profile":
+ return parse_profile_args(argv[1:])
+ return parse_benchmark_args(argv)
+
+
+def parse_benchmark_args(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Collect or reuse egglog-experimental benchmark reports.",
)
@@ -308,6 +412,12 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
default=DEFAULT_REPORT,
help=f"JSONL report/cache path, or - for stdout (default: {DEFAULT_REPORT})",
)
+ parser.add_argument(
+ "--format",
+ choices=("rich", "markdown"),
+ default="rich",
+ help="final report format: rich to stderr, or markdown to stdout (default: rich)",
+ )
parser.add_argument(
"--rounds",
type=positive_int,
@@ -320,6 +430,11 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
default=DEFAULT_TIMEOUT_SEC,
help=f"per-process timeout in seconds (default: {DEFAULT_TIMEOUT_SEC})",
)
+ parser.add_argument(
+ "--backend",
+ default=",".join(DEFAULT_BACKENDS),
+ help=f"comma-separated backends: {', '.join(BACKEND_SPECS)} (default: {','.join(DEFAULT_BACKENDS)})",
+ )
parser.add_argument(
"--treatments",
default=",".join(DEFAULT_TREATMENTS),
@@ -330,7 +445,89 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
action="store_true",
help="append fresh rows even when enough cached rows exist",
)
- return parser.parse_args(argv)
+ args = parser.parse_args(argv)
+ if args.format == "markdown" and args.report == "-":
+ parser.error("--format markdown cannot be combined with --report -")
+ args.command = "benchmark"
+ return args
+
+
+def parse_profile_args(argv: Sequence[str]) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ prog=f"{Path(sys.argv[0]).name} profile",
+ description="Record or reuse a cached Samply CPU profile for one egglog workload.",
+ )
+ parser.add_argument("file", help="egglog file to profile")
+ parser.add_argument(
+ "--target",
+ action="append",
+ default=None,
+ help="target source: ., /path, @git-ref, #pr, or label=source; cache-only label= is not supported",
+ )
+ parser.add_argument(
+ "--backend",
+ default="main",
+ help="single backend to profile (default: main)",
+ )
+ parser.add_argument(
+ "--treatment",
+ default="proofs",
+ help="single treatment to profile (default: proofs)",
+ )
+ iteration_group = parser.add_mutually_exclusive_group()
+ iteration_group.add_argument(
+ "--iterations",
+ type=positive_int,
+ default=None,
+ help="explicit Samply iteration count",
+ )
+ iteration_group.add_argument(
+ "--profile-seconds",
+ type=positive_int,
+ default=None,
+ help=f"target duration for automatic iteration selection (default: {DEFAULT_PROFILE_SECONDS})",
+ )
+ parser.add_argument(
+ "--profiles-dir",
+ default=DEFAULT_PROFILES_DIR,
+ help=f"profile cache directory (default: {DEFAULT_PROFILES_DIR})",
+ )
+ parser.add_argument(
+ "--top",
+ type=positive_int,
+ default=DEFAULT_PROFILE_TOP,
+ help=f"application functions to show in the macOS CPU summary (default: {DEFAULT_PROFILE_TOP})",
+ )
+ parser.add_argument(
+ "--no-summary",
+ action="store_true",
+ help="print only the profile artifact path",
+ )
+ parser.add_argument(
+ "--format",
+ choices=("rich", "markdown"),
+ default="rich",
+ help="summary format: rich to stderr, or markdown to stdout (default: rich)",
+ )
+ parser.add_argument(
+ "--open",
+ action="store_true",
+ help="open the profile with samply load after recording or cache hit",
+ )
+ parser.add_argument(
+ "--force-run",
+ action="store_true",
+ help="record again and atomically replace the cached profile",
+ )
+ parser.add_argument(
+ "--timeout-sec",
+ type=positive_int,
+ default=DEFAULT_TIMEOUT_SEC,
+ help=f"per-workload timeout in seconds for calibration and profiling watchdog (default: {DEFAULT_TIMEOUT_SEC})",
+ )
+ args = parser.parse_args(argv)
+ args.command = "profile"
+ return args
def positive_int(value: str) -> int:
@@ -358,6 +555,86 @@ def parse_treatments(value: str) -> tuple[Treatment, ...]:
return tuple(treatments)
+def parse_backends(value: str) -> tuple[Backend, ...]:
+ backends: list[Backend] = []
+ seen: set[str] = set()
+ for raw in value.split(","):
+ item = raw.strip()
+ if not item:
+ continue
+ if item not in BACKEND_SPECS:
+ raise ValueError(f"unknown backend: {item}")
+ if item in seen:
+ raise ValueError(f"duplicate backend: {item}")
+ seen.add(item)
+ backends.append(item)
+ if not backends:
+ raise ValueError("at least one backend is required")
+ return tuple(backends)
+
+
+def backend_spec(backend: Backend) -> BackendSpec:
+ try:
+ return BACKEND_SPECS[backend]
+ except KeyError as error:
+ raise ValueError(f"unknown backend: {backend}") from error
+
+
+def backend_supports_treatment(backend: Backend, treatment: Treatment) -> bool:
+ return treatment in backend_spec(backend).treatments
+
+
+def supported_treatments(backend: Backend) -> tuple[Treatment, ...]:
+ return backend_spec(backend).treatments
+
+
+def backend_cargo_features(backends: Sequence[Backend]) -> tuple[str, ...]:
+ return tuple(dict.fromkeys(feature for backend in backends for feature in backend_spec(backend).cargo_features))
+
+
+def backend_treatment_cells(
+ backends: Sequence[Backend],
+ treatments: Sequence[Treatment],
+) -> tuple[BenchmarkCell, ...]:
+ cells: list[BenchmarkCell] = []
+ requested = ",".join(treatments)
+ for backend in backends:
+ backend_cells = tuple(
+ BenchmarkCell(backend, treatment)
+ for treatment in treatments
+ if backend_supports_treatment(backend, treatment)
+ )
+ if not backend_cells:
+ supported = ",".join(supported_treatments(backend))
+ raise ValueError(
+ f"backend {backend} has no supported treatments in requested set {requested}; "
+ f"supported treatments: {supported}"
+ )
+ cells.extend(backend_cells)
+ return tuple(cells)
+
+
+def benchmark_cells(spec: BenchmarkSpec) -> tuple[BenchmarkCell, ...]:
+ return backend_treatment_cells(spec.backends, spec.treatments)
+
+
+def backend_has_treatment(spec: BenchmarkSpec, backend: Backend, treatment: Treatment) -> bool:
+ return any(cell.backend == backend and cell.treatment == treatment for cell in benchmark_cells(spec))
+
+
+def shared_backend_treatments(
+ spec: BenchmarkSpec,
+ baseline_backend: Backend,
+ candidate_backend: Backend,
+) -> tuple[Treatment, ...]:
+ return tuple(
+ treatment
+ for treatment in spec.treatments
+ if backend_supports_treatment(baseline_backend, treatment)
+ and backend_supports_treatment(candidate_backend, treatment)
+ )
+
+
def parse_target(raw: str) -> TargetRequest:
if "=" in raw:
label, source = raw.split("=", 1)
@@ -453,6 +730,7 @@ def file_contains_executable_prove_command(path: Path) -> bool:
def validate_spec(spec: BenchmarkSpec) -> None:
+ benchmark_cells(spec)
for file_spec in spec.files:
if file_contains_executable_prove_command(file_spec.absolute_path):
raise ValueError(
@@ -461,6 +739,61 @@ def validate_spec(spec: BenchmarkSpec) -> None:
)
+def parse_single_backend(value: str) -> Backend:
+ backends = parse_backends(value)
+ if len(backends) != 1:
+ raise ValueError("profile mode requires exactly one backend")
+ return backends[0]
+
+
+def parse_single_treatment(value: str) -> Treatment:
+ treatments = parse_treatments(value)
+ if len(treatments) != 1:
+ raise ValueError("profile mode requires exactly one treatment")
+ return treatments[0]
+
+
+def resolve_profile_request(args: argparse.Namespace, invocation_cwd: Path) -> ProfileRequest:
+ files = resolve_files([str(args.file)], invocation_cwd)
+ backend = parse_single_backend(str(args.backend))
+ treatment = parse_single_treatment(str(args.treatment))
+ target_specs = args.target if args.target is not None else ["."]
+ if len(target_specs) != 1:
+ raise ValueError("profile mode requires exactly one target")
+ if args.iterations is not None:
+ mode = ProfileMode(iterations=args.iterations, profile_seconds=None)
+ else:
+ profile_seconds = args.profile_seconds if args.profile_seconds is not None else DEFAULT_PROFILE_SECONDS
+ mode = ProfileMode(iterations=None, profile_seconds=profile_seconds)
+ profiles_dir = Path(str(args.profiles_dir)).expanduser()
+ if not profiles_dir.is_absolute():
+ profiles_dir = invocation_cwd / profiles_dir
+ request = ProfileRequest(
+ file=files[0],
+ target_request=parse_target(str(target_specs[0])),
+ backend=backend,
+ treatment=treatment,
+ timeout_sec=int(args.timeout_sec),
+ profiles_dir=profiles_dir,
+ mode=mode,
+ open_after=bool(args.open),
+ force_run=bool(args.force_run),
+ top=int(args.top),
+ show_summary=not bool(args.no_summary),
+ output_format=cast(OutputFormat, str(args.format)),
+ )
+ validate_spec(
+ BenchmarkSpec(
+ files=(request.file,),
+ treatments=(request.treatment,),
+ rounds=1,
+ timeout_sec=request.timeout_sec,
+ backends=(request.backend,),
+ )
+ )
+ return request
+
+
def resolve_report_destination(raw_report: str, invocation_cwd: Path) -> ReportDestination:
if raw_report == "-":
return ReportDestination(path=None, stream=sys.stdout)
@@ -502,10 +835,13 @@ def normalize_report_frame(frame: pd.DataFrame) -> DataFrame[ReportFrame]:
extra = sorted(set(normalized.columns) - set(columns))
if extra:
raise ValueError(f"unexpected report column(s): {', '.join(extra)}")
+ if "backend" not in normalized.columns:
+ normalized["backend"] = "main"
for column in columns:
if column not in normalized.columns:
normalized[column] = pd.NA
normalized = normalized.loc[:, columns]
+ normalized["backend"] = normalized["backend"].fillna("main")
normalized["started_at"] = pd.to_datetime(normalized["started_at"], utc=True, errors="raise")
for column in ["wall_sec", "user_sec", "system_sec", "cpu_wall_ratio"]:
normalized[column] = pd.to_numeric(normalized[column], errors="raise")
@@ -573,6 +909,7 @@ def from_rows(cls, rows: DataFrame[ReportFrame]) -> EstimateModel:
file_sha256=str(record["file_sha256"]),
treatment=cast(Treatment, str(record["treatment"])),
timeout_sec=int(record["timeout_sec"]),
+ backend=cast(Backend, str(record["backend"])),
)
samples.setdefault(key, []).append(float(record["wall_sec"]))
return cls(samples)
@@ -626,6 +963,7 @@ def selected_rows(
matches = rows.loc[
rows["binary_sha256"].eq(key.binary_sha256)
& rows["file_sha256"].eq(key.file_sha256)
+ & rows["backend"].eq(key.backend)
& rows["treatment"].eq(key.treatment)
& rows["timeout_sec"].eq(key.timeout_sec)
]
@@ -641,6 +979,7 @@ def status_counts_for_rows(rows: DataFrame[ReportFrame]) -> dict[str, int]:
def estimate_key_for(
target: ResolvedTarget,
file_spec: FileSpec,
+ backend: Backend,
treatment: Treatment,
timeout_sec: int,
) -> EstimateKey:
@@ -649,6 +988,7 @@ def estimate_key_for(
file_sha256=file_spec.sha256,
treatment=treatment,
timeout_sec=timeout_sec,
+ backend=backend,
)
@@ -660,8 +1000,8 @@ def build_collection_plan(
) -> CollectionPlan:
cells: list[CellPlan] = []
for file_spec in spec.files:
- for treatment in spec.treatments:
- estimate_key = estimate_key_for(target, file_spec, treatment, spec.timeout_sec)
+ for cell in benchmark_cells(spec):
+ estimate_key = estimate_key_for(target, file_spec, cell.backend, cell.treatment, spec.timeout_sec)
cached = selected_rows(
rows,
estimate_key,
@@ -672,7 +1012,8 @@ def build_collection_plan(
CellPlan(
target=target,
file=file_spec,
- treatment=treatment,
+ backend=cell.backend,
+ treatment=cell.treatment,
required_rows=spec.rounds,
selected_cached_rows=cached,
missing_observations=missing,
@@ -786,24 +1127,62 @@ def target_row_for_request(
)
-def build_target(row: TargetRow, output: RunnerOutput) -> tuple[Path, str]:
+def build_target(
+ row: TargetRow,
+ output: RunnerOutput,
+ build_profile: BuildProfile = "release",
+ cargo_features: Sequence[str] = (),
+) -> tuple[Path, str]:
checkout_path = Path(row.path)
output.build_start(row)
+ if build_profile == "release":
+ build_args = ["cargo", "build", "--release", "-p", "egglog-experimental"]
+ else:
+ build_args = ["cargo", "build", "--profile", "profiling", "-p", "egglog-experimental"]
+ if cargo_features:
+ build_args.extend(("--features", ",".join(cargo_features)))
subprocess.run(
- ["cargo", "build", "--release", "-p", "egglog-experimental"],
+ build_args,
cwd=checkout_path,
check=True,
stdout=sys.stderr,
stderr=sys.stderr,
)
binary_name = "egglog-experimental.exe" if os.name == "nt" else "egglog-experimental"
- binary_path = checkout_path / "target" / "release" / binary_name
+ binary_path = checkout_path / "target" / build_profile / binary_name
if not binary_path.is_file():
- raise FileNotFoundError(f"release binary was not produced: {binary_path}")
+ raise FileNotFoundError(f"{build_profile} binary was not produced: {binary_path}")
binary_sha256 = sha256_file(binary_path)
return (binary_path, binary_sha256)
+def materialize_target_request(request: TargetRequest, invocation_cwd: Path, repo_root: Path) -> TargetRow:
+ if request.is_label_lookup:
+ raise ValueError("cache-only target labels cannot be materialized without a cached report row")
+ if request.source.startswith("@"):
+ ref = request.source[1:]
+ if not ref:
+ raise ValueError(f"git target is missing a ref: {request.raw}")
+ checkout_path, git_ref_value = materialize_git_ref(repo_root, ref, request.label or ref)
+ elif parse_pr_number(request.source) is not None:
+ checkout_path, git_ref_value = materialize_pr_target(repo_root, request.source, request.label)
+ else:
+ checkout_path, git_ref_value = resolve_path_target(request.source, invocation_cwd)
+ return target_row_for_request(request, checkout_path, git_ref_value)
+
+
+def build_resolved_target(
+ request: TargetRequest,
+ row: TargetRow,
+ output: RunnerOutput,
+ build_profile: BuildProfile,
+ cargo_features: Sequence[str],
+) -> ResolvedTarget:
+ binary_path, binary_sha256 = build_target(row, output, build_profile, cargo_features)
+ row = replace(row, is_dirty=git_dirty(Path(row.path)))
+ return ResolvedTarget(request=request, row=row, binary_sha256=binary_sha256, binary_path=binary_path)
+
+
def resolve_target(
request: TargetRequest,
rows: DataFrame[ReportFrame],
@@ -835,26 +1214,23 @@ def resolve_target(
)
checkout_path, resolved_sha = materialize_git_ref(repo_root, str(pointer["target_git_sha"]), request.label)
row = target_row_for_request(request, checkout_path, resolved_sha)
- binary_path, binary_sha256 = build_target(row, output)
- row = replace(row, is_dirty=git_dirty(checkout_path))
- return ResolvedTarget(request=request, row=row, binary_sha256=binary_sha256, binary_path=binary_path)
+ return build_resolved_target(request, row, output, "release", backend_cargo_features(spec.backends))
- if request.source.startswith("@"):
- ref = request.source[1:]
- if not ref:
- raise ValueError(f"git target is missing a ref: {request.raw}")
- checkout_path, resolved_sha = materialize_git_ref(repo_root, ref, request.label or ref)
- row = target_row_for_request(request, checkout_path, resolved_sha)
- elif parse_pr_number(request.source) is not None:
- checkout_path, resolved_sha = materialize_pr_target(repo_root, request.source, request.label)
- row = target_row_for_request(request, checkout_path, resolved_sha)
- else:
- checkout_path, git_ref_value = resolve_path_target(request.source, invocation_cwd)
- row = target_row_for_request(request, checkout_path, git_ref_value)
+ row = materialize_target_request(request, invocation_cwd, repo_root)
+ return build_resolved_target(request, row, output, "release", backend_cargo_features(spec.backends))
- binary_path, binary_sha256 = build_target(row, output)
- row = replace(row, is_dirty=git_dirty(Path(row.path)))
- return ResolvedTarget(request=request, row=row, binary_sha256=binary_sha256, binary_path=binary_path)
+
+def resolve_profile_target(
+ request: TargetRequest,
+ backend: Backend,
+ invocation_cwd: Path,
+ repo_root: Path,
+ output: RunnerOutput,
+) -> ResolvedTarget:
+ if request.is_label_lookup:
+ raise ValueError("profile mode does not support cache-only label= targets; use label=SOURCE")
+ row = materialize_target_request(request, invocation_cwd, repo_root)
+ return build_resolved_target(request, row, output, "profiling", backend_cargo_features((backend,)))
def label_has_enough_rows(
@@ -863,10 +1239,10 @@ def label_has_enough_rows(
spec: BenchmarkSpec,
) -> bool:
for file_spec in spec.files:
- for treatment in spec.treatments:
+ for cell in benchmark_cells(spec):
matches = selected_rows(
rows,
- EstimateKey(binary_sha256, file_spec.sha256, treatment, spec.timeout_sec),
+ EstimateKey(binary_sha256, file_spec.sha256, cell.treatment, spec.timeout_sec, cell.backend),
spec.rounds,
)
if len(matches) < spec.rounds:
@@ -919,29 +1295,196 @@ def treatment_flags(treatment: Treatment) -> list[str]:
return ["--proofs"]
-def run_process(
+def backend_flags(backend: Backend) -> list[str]:
+ return list(backend_spec(backend).flags)
+
+
+def workload_command(
binary_path: Path,
- checkout_path: Path,
file_spec: FileSpec,
+ backend: Backend,
treatment: Treatment,
- timeout_sec: int,
-) -> TimingResult:
- command = [
+) -> list[str]:
+ return [
str(binary_path),
"--mode",
"no-messages",
"-j",
"1",
+ *backend_flags(backend),
*treatment_flags(treatment),
str(file_spec.absolute_path),
]
- return run_command(command, checkout_path, timeout_sec)
+
+
+def run_process(
+ binary_path: Path,
+ checkout_path: Path,
+ file_spec: FileSpec,
+ backend: Backend,
+ treatment: Treatment,
+ timeout_sec: int,
+) -> TimingResult:
+ return run_command(workload_command(binary_path, file_spec, backend, treatment), checkout_path, timeout_sec)
def run_startup_warmup(binary_path: Path, checkout_path: Path, timeout_sec: int) -> TimingResult:
return run_command([str(binary_path), "--help"], checkout_path, timeout_sec)
+def profile_hash_component(value: str) -> str:
+ return value.removeprefix("sha256:")
+
+
+def profile_cache_path(
+ profiles_dir: Path,
+ binary_sha256: str,
+ file_sha256: str,
+ backend: Backend,
+ treatment: Treatment,
+ mode: ProfileMode,
+) -> Path:
+ return (
+ profiles_dir
+ / PROFILE_CACHE_VERSION
+ / profile_hash_component(binary_sha256)
+ / profile_hash_component(file_sha256)
+ / f"{backend}-{treatment}-{mode.cache_label}.json.gz"
+ )
+
+
+def profile_cache_hit(path: Path) -> bool:
+ return path.is_file() and path.stat().st_size > 0
+
+
+def profile_display_path(path: Path, invocation_cwd: Path) -> Path:
+ resolved = path.resolve()
+ try:
+ return resolved.relative_to(invocation_cwd.resolve())
+ except ValueError:
+ return resolved
+
+
+def calculate_profile_iterations(
+ elapsed_seconds: float,
+ profile_seconds: int,
+ max_iterations: int = MAX_PROFILE_ITERATIONS,
+) -> tuple[int, bool]:
+ if elapsed_seconds <= 0:
+ return (max_iterations, True)
+ uncapped = max(1, math.ceil(profile_seconds * 1.10 / elapsed_seconds))
+ if uncapped > max_iterations:
+ return (max_iterations, True)
+ return (uncapped, False)
+
+
+def profile_name(
+ file_spec: FileSpec,
+ backend: Backend,
+ treatment: Treatment,
+ mode: ProfileMode,
+ iterations: int,
+ binary_sha256: str,
+) -> str:
+ stem = Path(file_spec.display_path).stem
+ file_hash = profile_hash_component(file_spec.sha256)[:8]
+ binary_hash = profile_hash_component(binary_sha256)[:8]
+ mode_label = mode.cache_label if mode.iterations is not None else f"auto>={mode.profile_seconds}s"
+ return f"{stem} {backend}/{treatment} {mode_label} x{iterations} [bin:{binary_hash} file:{file_hash}]"
+
+
+def profile_temp_path(artifact: Path) -> Path:
+ base_name = artifact.name.removesuffix(".json.gz")
+ return artifact.with_name(f".{base_name}.tmp-{uuid.uuid4().hex}.json.gz")
+
+
+def samply_executable() -> str:
+ executable = shutil.which("samply")
+ if executable is None:
+ raise FileNotFoundError("Install Samply with: cargo install --locked samply")
+ return executable
+
+
+def samply_record_command(
+ samply: str,
+ artifact: Path,
+ name: str,
+ iterations: int,
+ workload: Sequence[str],
+) -> list[str]:
+ return [
+ samply,
+ "record",
+ "--save-only",
+ "--rate",
+ str(PROFILE_SAMPLY_RATE_HZ),
+ "--reuse-threads",
+ "--iteration-count",
+ str(iterations),
+ "--profile-name",
+ name,
+ "--output",
+ str(artifact),
+ "--",
+ *workload,
+ ]
+
+
+def profile_record_timeout(timeout_sec: int, iterations: int) -> int:
+ return max(timeout_sec + 60, timeout_sec * iterations + 60)
+
+
+def run_samply_record(
+ *,
+ artifact: Path,
+ name: str,
+ iterations: int,
+ workload: Sequence[str],
+ checkout_path: Path,
+ timeout_sec: int,
+) -> dict[str, Any]:
+ temp_artifact = profile_temp_path(artifact)
+ temp_artifact.parent.mkdir(parents=True, exist_ok=True)
+ with suppress(FileNotFoundError):
+ temp_artifact.unlink()
+ command = samply_record_command(samply_executable(), temp_artifact, name, iterations, workload)
+ env = os.environ.copy()
+ env["RUST_LOG"] = "error"
+ try:
+ subprocess.run(
+ command,
+ cwd=checkout_path,
+ env=env,
+ check=True,
+ timeout=profile_record_timeout(timeout_sec, iterations),
+ stdout=sys.stderr,
+ stderr=sys.stderr,
+ )
+ if not profile_cache_hit(temp_artifact):
+ raise ValueError(f"Samply did not produce a nonempty profile artifact: {temp_artifact}")
+ profile = samply_analysis.read_artifact(temp_artifact)
+ artifact.parent.mkdir(parents=True, exist_ok=True)
+ os.replace(temp_artifact, artifact)
+ return profile
+ except BaseException:
+ with suppress(FileNotFoundError):
+ temp_artifact.unlink()
+ raise
+
+
+def open_samply_profile(artifact: Path, checkout_path: Path) -> None:
+ try:
+ subprocess.run(
+ [samply_executable(), "load", str(artifact)],
+ cwd=checkout_path,
+ check=True,
+ stdout=sys.stderr,
+ stderr=sys.stderr,
+ )
+ except KeyboardInterrupt:
+ return
+
+
def run_command(command: Sequence[str], checkout_path: Path, timeout_sec: int) -> TimingResult:
env = os.environ.copy()
env["RUST_LOG"] = "error"
@@ -1028,12 +1571,13 @@ def ru_maxrss_to_bytes(ru_maxrss: int, platform: str = sys.platform) -> int | No
def collection_label(
file_spec: FileSpec,
+ backend: Backend,
treatment: Treatment,
round_index: int,
rounds: int,
) -> str:
filename = Path(file_spec.display_path).name
- return f"{filename} {treatment} {round_index + 1}/{rounds}"
+ return f"{filename} {backend}/{treatment} {round_index + 1}/{rounds}"
def format_timing_result(result: TimingResult) -> str:
@@ -1086,8 +1630,8 @@ def emit_collection_plan(
) -> None:
total_estimate = collection_plan_estimate(plan, estimate_model)
table = Table(title=f"{plan.target.display_label}: cache and estimate plan")
- table.add_column("File")
- table.add_column("Treatment")
+ table.add_column("File", overflow="fold")
+ table.add_column("Cell", no_wrap=True)
table.add_column("Cached")
table.add_column("Missing")
table.add_column("Statuses")
@@ -1100,7 +1644,7 @@ def emit_collection_plan(
statuses = ", ".join(f"{status}:{count}" for status, count in sorted(status_counts.items())) or "-"
table.add_row(
cell.file.display_path,
- cell.treatment,
+ f"{cell.backend}/{cell.treatment}",
f"{len(cell.selected_cached_rows)}/{cell.required_rows}",
str(cell.missing_observations),
statuses,
@@ -1133,6 +1677,7 @@ def flat_report_record(
"binary_sha256": target.binary_sha256,
"file_path": cell.file.display_path,
"file_sha256": cell.file.sha256,
+ "backend": cell.backend,
"treatment": cell.treatment,
"timeout_sec": spec.timeout_sec,
"wall_sec": result.timing.wall_sec,
@@ -1185,22 +1730,21 @@ def decrement_remaining(key: EstimateKey, count: int = 1) -> None:
else:
remaining_processes.pop(key, None)
- def run_loop(progress: Progress | None = None, process_task: Any | None = None) -> None:
+ def run_loop(progress: Progress, process_task: TaskID) -> None:
nonlocal next_index, completed_observations
for round_index in range(max_deficit):
for cell in plan.cells:
if round_index >= cell.missing_observations:
continue
cell_file = cell.file
+ cell_backend = cell.backend
cell_treatment = cell.treatment
cell_key = cell.estimate_key
required_rounds = cell.missing_observations
observation_number = completed_observations + 1
- label = collection_label(cell_file, cell_treatment, round_index, required_rounds)
+ label = collection_label(cell_file, cell_backend, cell_treatment, round_index, required_rounds)
def update_progress(current: str, advance: int = 0) -> None:
- if progress is None or process_task is None:
- return
progress.update(
process_task,
advance=advance,
@@ -1210,7 +1754,14 @@ def update_progress(current: str, advance: int = 0) -> None:
update_progress(f"row {observation_number}/{total_observations} timed")
started_at = now_iso()
- result = run_process(binary_path, Path(target.row.path), cell_file, cell_treatment, spec.timeout_sec)
+ result = run_process(
+ binary_path,
+ Path(target.row.path),
+ cell_file,
+ cell_backend,
+ cell_treatment,
+ spec.timeout_sec,
+ )
estimate_model.record_process(cell_key, result)
decrement_remaining(cell_key)
update_progress(
@@ -1230,7 +1781,6 @@ def update_progress(current: str, advance: int = 0) -> None:
fresh_frames.append(fresh_frame)
next_index += 1
completed_observations += 1
- assert progress is not None
progress.console.print(f" {label}: fresh {format_timing_result(result)}")
update_progress(
f"rows {completed_observations}/{total_observations}; last {format_timing_result(result)}"
@@ -1443,20 +1993,22 @@ def target_cell_summaries(
rows: DataFrame[ReportFrame],
target: ResolvedTarget,
spec: BenchmarkSpec,
+ backends: Sequence[Backend] | None = None,
treatments: Sequence[Treatment] | None = None,
) -> CellMap:
+ chosen_backends = spec.backends if backends is None else backends
chosen_treatments = spec.treatments if treatments is None else treatments
return {
- (file_spec.sha256, treatment): summarize_cell(
+ (file_spec.sha256, cell.backend, cell.treatment): summarize_cell(
selected_rows(
rows,
- estimate_key_for(target, file_spec, treatment, spec.timeout_sec),
+ estimate_key_for(target, file_spec, cell.backend, cell.treatment, spec.timeout_sec),
spec.rounds,
),
spec.rounds,
)
for file_spec in spec.files
- for treatment in chosen_treatments
+ for cell in backend_treatment_cells(chosen_backends, chosen_treatments)
}
@@ -1464,20 +2016,22 @@ def target_rss_cell_summaries(
rows: DataFrame[ReportFrame],
target: ResolvedTarget,
spec: BenchmarkSpec,
+ backends: Sequence[Backend] | None = None,
treatments: Sequence[Treatment] | None = None,
) -> CellMap:
+ chosen_backends = spec.backends if backends is None else backends
chosen_treatments = spec.treatments if treatments is None else treatments
return {
- (file_spec.sha256, treatment): summarize_rss_cell(
+ (file_spec.sha256, cell.backend, cell.treatment): summarize_rss_cell(
selected_rows(
rows,
- estimate_key_for(target, file_spec, treatment, spec.timeout_sec),
+ estimate_key_for(target, file_spec, cell.backend, cell.treatment, spec.timeout_sec),
spec.rounds,
),
spec.rounds,
)
for file_spec in spec.files
- for treatment in chosen_treatments
+ for cell in backend_treatment_cells(chosen_backends, chosen_treatments)
}
@@ -1486,12 +2040,13 @@ def target_suite_treatment_ratio(
candidate_cells: CellMap,
spec: BenchmarkSpec,
treatment: Treatment,
+ backend: Backend = "main",
) -> RatioSummary:
return suite_ratio(
[
(
- baseline_cells[(file_spec.sha256, treatment)],
- candidate_cells[(file_spec.sha256, treatment)],
+ baseline_cells[(file_spec.sha256, backend, treatment)],
+ candidate_cells[(file_spec.sha256, backend, treatment)],
)
for file_spec in spec.files
]
@@ -1501,14 +2056,15 @@ def target_suite_treatment_ratio(
def treatment_file_cells(
cell_map: CellMap,
spec: BenchmarkSpec,
+ backend: Backend,
baseline_treatment: Treatment,
candidate_treatment: Treatment,
) -> list[tuple[FileSpec, CellSummary, CellSummary]]:
return [
(
file_spec,
- cell_map[(file_spec.sha256, baseline_treatment)],
- cell_map[(file_spec.sha256, candidate_treatment)],
+ cell_map[(file_spec.sha256, backend, baseline_treatment)],
+ cell_map[(file_spec.sha256, backend, candidate_treatment)],
)
for file_spec in spec.files
]
@@ -1518,13 +2074,31 @@ def target_treatment_file_cells(
baseline_cells: CellMap,
candidate_cells: CellMap,
spec: BenchmarkSpec,
+ backend: Backend,
+ treatment: Treatment,
+) -> list[tuple[FileSpec, CellSummary, CellSummary]]:
+ return [
+ (
+ file_spec,
+ baseline_cells[(file_spec.sha256, backend, treatment)],
+ candidate_cells[(file_spec.sha256, backend, treatment)],
+ )
+ for file_spec in spec.files
+ ]
+
+
+def backend_treatment_file_cells(
+ cell_map: CellMap,
+ spec: BenchmarkSpec,
+ baseline_backend: Backend,
+ candidate_backend: Backend,
treatment: Treatment,
) -> list[tuple[FileSpec, CellSummary, CellSummary]]:
return [
(
file_spec,
- baseline_cells[(file_spec.sha256, treatment)],
- candidate_cells[(file_spec.sha256, treatment)],
+ cell_map[(file_spec.sha256, baseline_backend, treatment)],
+ cell_map[(file_spec.sha256, candidate_backend, treatment)],
)
for file_spec in spec.files
]
@@ -1550,6 +2124,37 @@ def worst_file_ratio(ratios: Sequence[tuple[FileSpec, RatioSummary]]) -> tuple[F
return max(valid, key=lambda item: item[1].point or 0.0)
+def best_file_ratio(ratios: Sequence[tuple[FileSpec, RatioSummary]]) -> tuple[FileSpec | None, RatioSummary]:
+ if not ratios:
+ return (None, RatioSummary(None, None, None, "no files"))
+ valid = [(file_spec, ratio) for file_spec, ratio in ratios if ratio.point is not None]
+ if valid:
+ return min(valid, key=lambda item: item[1].point or math.inf)
+ return ratios[0]
+
+
+def count_better_files(ratios: Sequence[tuple[FileSpec, RatioSummary]]) -> tuple[int, int]:
+ valid = [ratio for _, ratio in ratios if ratio.point is not None]
+ better = sum(1 for ratio in valid if ratio.ci_high is not None and ratio.ci_high < 1.0)
+ return (better, len(valid))
+
+
+def format_better_file_count(count: tuple[int, int]) -> str:
+ better, total = count
+ if total == 0:
+ return "-"
+ return f"{better}/{total}"
+
+
+def suite_total_mean(cells: Sequence[CellSummary]) -> float | None:
+ total = 0.0
+ for cell in cells:
+ if not cell.ok or cell.mean is None:
+ return None
+ total += cell.mean
+ return total
+
+
def format_estimate_or_interval(
point: float | None,
low: float | None,
@@ -1569,6 +2174,10 @@ def format_seconds_summary(summary: CellSummary) -> str:
return format_estimate_or_interval(summary.mean, summary.ci_low, summary.ci_high, "s", 4)
+def format_seconds_value(value: float | None) -> str:
+ return "-" if value is None else f"{value:.4f}s"
+
+
def format_bytes(value: float | None) -> str:
if value is None:
return "-"
@@ -1635,6 +2244,10 @@ def format_comparison_result(summary: RatioSummary) -> Text:
return styled_status(result)
+def comparison_result_text(summary: RatioSummary) -> str:
+ return format_comparison_result(summary).plain
+
+
def lower_is_better_result(summary: RatioSummary) -> str:
if summary.point is None:
return "invalid"
@@ -1654,6 +2267,10 @@ def format_lower_is_better_result(summary: RatioSummary) -> Text:
return styled_status(result)
+def lower_is_better_result_text(summary: RatioSummary) -> str:
+ return format_lower_is_better_result(summary).plain
+
+
def proof_gate_result(summary: RatioSummary) -> tuple[str, str]:
if summary.point is None:
return ("invalid", f"invalid: {summary.issue or 'unavailable'}")
@@ -1669,6 +2286,14 @@ def format_proof_gate_result(summary: RatioSummary) -> Text:
return styled_status(status, text)
+def proof_gate_result_text(summary: RatioSummary) -> str:
+ return proof_gate_result(summary)[1]
+
+
+def report_row(*values: object) -> tuple[str, ...]:
+ return tuple(str(value) for value in values)
+
+
def report_table(title: str, *, caption: str | None = None, show_lines: bool = False) -> Table:
return Table(
title=title,
@@ -1682,6 +2307,69 @@ def report_table(title: str, *, caption: str | None = None, show_lines: bool = F
)
+def rich_result_cell(value: str) -> str | Text:
+ if value.startswith("invalid:"):
+ return styled_status("invalid", value)
+ if value == "<2x established":
+ return styled_status("established", value)
+ if value == "<2x not established":
+ return styled_status("not established", value)
+ if value in RESULT_STYLES:
+ return styled_status(value)
+ return value
+
+
+def render_rich_table(table_data: ReportTableData) -> Table:
+ table = report_table(table_data.title, caption=table_data.caption)
+ alignments = table_data.alignments or tuple("left" for _ in table_data.headers)
+ for header, alignment in zip(table_data.headers, alignments, strict=True):
+ table.add_column(header, no_wrap=alignment == "right", justify="right" if alignment == "right" else "left")
+ result_columns = {"Result", "Best result"}
+ for row in table_data.rows:
+ values: list[str | Text] = []
+ for header, value in zip(table_data.headers, row, strict=True):
+ values.append(rich_result_cell(value) if header in result_columns else value)
+ table.add_row(*values)
+ return table
+
+
+def markdown_escape_cell(value: object) -> str:
+ text = str(value)
+ return text.replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "\n").replace("\n", "
")
+
+
+def render_markdown_table(table_data: ReportTableData, *, heading_level: int = 3) -> str:
+ alignments = table_data.alignments or tuple("left" for _ in table_data.headers)
+ separator_cells = tuple("---:" if alignment == "right" else "---" for alignment in alignments)
+ lines = [
+ f"{'#' * heading_level} {table_data.title}",
+ "",
+ "| " + " | ".join(markdown_escape_cell(header) for header in table_data.headers) + " |",
+ "| " + " | ".join(separator_cells) + " |",
+ ]
+ for row in table_data.rows:
+ lines.append("| " + " | ".join(markdown_escape_cell(value) for value in row) + " |")
+ if table_data.caption:
+ lines.extend(["", f"*{markdown_escape_cell(table_data.caption)}*"])
+ return "\n".join(lines)
+
+
+def benchmark_command_block(argv: Sequence[str]) -> str:
+ return "```shell\n$ " + shlex.join(["./bench.py", *argv]) + "\n```"
+
+
+def cell_label(spec: BenchmarkSpec, backend: Backend, treatment: Treatment) -> str:
+ if len(spec.backends) == 1:
+ return treatment
+ return f"{backend}/{treatment}"
+
+
+def backend_metric_label(spec: BenchmarkSpec, backend: Backend, label: str) -> str:
+ if len(spec.backends) == 1:
+ return label
+ return f"{backend} {label}"
+
+
def render_report(
console: Console,
report_destination: ReportDestination,
@@ -1700,6 +2388,9 @@ def render_report(
if len(targets) > 1:
render_per_file_wall_time_change(console, cell_maps, targets, spec)
render_per_file_peak_rss_change(console, rss_cell_maps, targets, spec)
+ if len(spec.backends) > 1:
+ render_per_file_backend_wall_time_change(console, cell_maps, targets, spec)
+ render_per_file_backend_peak_rss_change(console, rss_cell_maps, targets, spec)
for target in targets:
render_target_diagnostics(console, cell_maps[target], rss_cell_maps[target], target, spec)
@@ -1707,6 +2398,62 @@ def render_report(
render_benchmark_summary(console, cell_maps, rss_cell_maps, targets, spec)
+def render_markdown_report(
+ report_destination: ReportDestination,
+ rows: DataFrame[ReportFrame],
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+ command_argv: Sequence[str] | None = None,
+) -> str:
+ cell_maps = {target: target_cell_summaries(rows, target, spec) for target in targets}
+ rss_cell_maps = {target: target_rss_cell_summaries(rows, target, spec) for target in targets}
+ parts = []
+ if command_argv is not None:
+ parts.append(benchmark_command_block(command_argv))
+ parts.extend(
+ [
+ "# Benchmark Report",
+ f"- Report: `{report_destination.display_path}`\n- Selected rows per cell: `{spec.rounds}`",
+ render_markdown_table(targets_table_data(targets), heading_level=2),
+ ]
+ )
+
+ comparison_tables: list[ReportTableData] = []
+ if len(targets) > 1:
+ comparison_tables.extend(per_file_wall_time_change_tables(cell_maps, targets, spec))
+ comparison_tables.extend(per_file_peak_rss_change_tables(rss_cell_maps, targets, spec))
+ if len(spec.backends) > 1:
+ comparison_tables.extend(per_file_backend_wall_time_change_tables(cell_maps, targets, spec))
+ comparison_tables.extend(per_file_backend_peak_rss_change_tables(rss_cell_maps, targets, spec))
+ if comparison_tables:
+ parts.append("## Comparisons")
+ parts.extend(render_markdown_table(table_data) for table_data in comparison_tables)
+
+ diagnostic_tables: list[ReportTableData] = []
+ for target in targets:
+ overhead = overhead_ratios_table_data(cell_maps[target], target, spec)
+ if overhead is not None:
+ diagnostic_tables.append(overhead)
+ diagnostic_tables.append(target_wall_time_table_data(cell_maps[target], target, spec))
+ peak_rss = target_peak_rss_table_data(rss_cell_maps[target], target, spec)
+ if peak_rss is not None:
+ diagnostic_tables.append(peak_rss)
+ if diagnostic_tables:
+ parts.append("## Target Diagnostics")
+ parts.extend(render_markdown_table(table_data) for table_data in diagnostic_tables)
+
+ summary_tables: list[ReportTableData] = list(backend_summary_tables(cell_maps, rss_cell_maps, targets, spec))
+ if len(targets) == 1:
+ summary_tables.append(
+ single_target_summary_table_data(cell_maps[targets[0]], rss_cell_maps[targets[0]], targets[0], spec)
+ )
+ else:
+ summary_tables.extend(multi_target_summary_tables(cell_maps, rss_cell_maps, targets, spec))
+ parts.append("## Benchmark Summary")
+ parts.extend(render_markdown_table(table_data) for table_data in summary_tables)
+ return "\n\n".join(part.strip() for part in parts if part.strip())
+
+
def render_targets_tree(console: Console, targets: Sequence[ResolvedTarget]) -> None:
tree = Tree("[bold]Targets[/bold]", guide_style="dim")
for index, target in enumerate(targets):
@@ -1725,75 +2472,236 @@ def render_targets_tree(console: Console, targets: Sequence[ResolvedTarget]) ->
console.print(tree)
-def render_per_file_wall_time_change(
- console: Console,
+def targets_table_data(targets: Sequence[ResolvedTarget]) -> ReportTableData:
+ rows: list[tuple[str, ...]] = []
+ for index, target in enumerate(targets):
+ role = "target"
+ if len(targets) > 1:
+ role = "baseline" if index == 0 else "candidate"
+ git = target.row.git_sha[:12]
+ if target.row.git_ref != "HEAD":
+ git = f"{git} ({target.row.git_ref})"
+ rows.append(
+ report_row(
+ role,
+ target.display_label,
+ git,
+ "yes" if target.row.is_dirty else "no",
+ target.binary_sha256.removeprefix("sha256:")[:12],
+ target.row.path,
+ )
+ )
+ return ReportTableData(
+ title="Targets",
+ headers=("Role", "Label", "Git", "Dirty", "Binary", "Path"),
+ rows=tuple(rows),
+ )
+
+
+def per_file_wall_time_change_tables(
cell_maps: TargetCellMaps,
targets: Sequence[ResolvedTarget],
spec: BenchmarkSpec,
-) -> None:
+) -> tuple[ReportTableData, ...]:
baseline = targets[0]
+ cells = benchmark_cells(spec)
+ tables: list[ReportTableData] = []
for target in targets[1:]:
- table = report_table(
- f"Per-file wall-time change vs {baseline.display_label}: {target.display_label}",
- caption=TARGET_WALL_TIME_CAPTION,
- )
- table.add_column("File")
- table.add_column("Treatment")
- table.add_column("Time ratio", no_wrap=True)
- table.add_column("Wall-time change", no_wrap=True)
- table.add_column("Result")
+ rows: list[tuple[str, ...]] = []
for file_spec in spec.files:
- for treatment_index, treatment in enumerate(spec.treatments):
+ for cell_index, cell in enumerate(cells):
ratio = ratio_summary(
- cell_maps[baseline][(file_spec.sha256, treatment)],
- cell_maps[target][(file_spec.sha256, treatment)],
+ cell_maps[baseline][(file_spec.sha256, cell.backend, cell.treatment)],
+ cell_maps[target][(file_spec.sha256, cell.backend, cell.treatment)],
)
- table.add_row(
- file_spec.display_path if treatment_index == 0 else "",
- treatment,
- format_ratio_summary(ratio),
- format_wall_time_change(ratio),
- format_comparison_result(ratio),
- end_section=treatment_index == len(spec.treatments) - 1,
+ rows.append(
+ report_row(
+ file_spec.display_path if cell_index == 0 else "",
+ cell.backend,
+ cell.treatment,
+ format_ratio_summary(ratio),
+ format_wall_time_change(ratio),
+ comparison_result_text(ratio),
+ )
)
- console.print(table)
+ tables.append(
+ ReportTableData(
+ title=f"Per-file wall-time change vs {baseline.display_label}: {target.display_label}",
+ headers=("File", "Backend", "Treatment", "Time ratio", "Wall-time change", "Result"),
+ rows=tuple(rows),
+ caption=TARGET_WALL_TIME_CAPTION,
+ alignments=("left", "left", "left", "right", "right", "left"),
+ )
+ )
+ return tuple(tables)
-def render_per_file_peak_rss_change(
+def render_per_file_wall_time_change(
console: Console,
- rss_cell_maps: TargetCellMaps,
+ cell_maps: TargetCellMaps,
targets: Sequence[ResolvedTarget],
spec: BenchmarkSpec,
) -> None:
+ for table_data in per_file_wall_time_change_tables(cell_maps, targets, spec):
+ console.print(render_rich_table(table_data))
+
+
+def per_file_peak_rss_change_tables(
+ rss_cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> tuple[ReportTableData, ...]:
baseline = targets[0]
baseline_has_rss = any(cell.samples for cell in rss_cell_maps[baseline].values())
+ cells = benchmark_cells(spec)
+ tables: list[ReportTableData] = []
for target in targets[1:]:
if not baseline_has_rss and not any(cell.samples for cell in rss_cell_maps[target].values()):
continue
- table = report_table(
- f"Per-file peak RSS change vs {baseline.display_label}: {target.display_label}",
- caption=TARGET_PEAK_RSS_CAPTION,
- )
- table.add_column("File")
- table.add_column("Treatment")
- table.add_column("RSS ratio", no_wrap=True)
- table.add_column("RSS change", no_wrap=True)
- table.add_column("Result")
+ rows: list[tuple[str, ...]] = []
for file_spec in spec.files:
- for treatment_index, treatment in enumerate(spec.treatments):
+ for cell_index, cell in enumerate(cells):
ratio = ratio_summary(
- rss_cell_maps[baseline][(file_spec.sha256, treatment)],
- rss_cell_maps[target][(file_spec.sha256, treatment)],
+ rss_cell_maps[baseline][(file_spec.sha256, cell.backend, cell.treatment)],
+ rss_cell_maps[target][(file_spec.sha256, cell.backend, cell.treatment)],
)
- table.add_row(
- file_spec.display_path if treatment_index == 0 else "",
- treatment,
- format_ratio_summary(ratio),
- format_percent_change(ratio),
- format_lower_is_better_result(ratio),
- end_section=treatment_index == len(spec.treatments) - 1,
+ rows.append(
+ report_row(
+ file_spec.display_path if cell_index == 0 else "",
+ cell.backend,
+ cell.treatment,
+ format_ratio_summary(ratio),
+ format_percent_change(ratio),
+ lower_is_better_result_text(ratio),
+ )
+ )
+ tables.append(
+ ReportTableData(
+ title=f"Per-file peak RSS change vs {baseline.display_label}: {target.display_label}",
+ headers=("File", "Backend", "Treatment", "RSS ratio", "RSS change", "Result"),
+ rows=tuple(rows),
+ caption=TARGET_PEAK_RSS_CAPTION,
+ alignments=("left", "left", "left", "right", "right", "left"),
+ )
+ )
+ return tuple(tables)
+
+
+def render_per_file_peak_rss_change(
+ console: Console,
+ rss_cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> None:
+ for table_data in per_file_peak_rss_change_tables(rss_cell_maps, targets, spec):
+ console.print(render_rich_table(table_data))
+
+
+def per_file_backend_wall_time_change_tables(
+ cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> tuple[ReportTableData, ...]:
+ baseline_backend = spec.backends[0]
+ tables: list[ReportTableData] = []
+ for target in targets:
+ for backend in spec.backends[1:]:
+ shared_treatments = shared_backend_treatments(spec, baseline_backend, backend)
+ if not shared_treatments:
+ continue
+ rows: list[tuple[str, ...]] = []
+ for file_spec in spec.files:
+ for treatment_index, treatment in enumerate(shared_treatments):
+ ratio = ratio_summary(
+ cell_maps[target][(file_spec.sha256, baseline_backend, treatment)],
+ cell_maps[target][(file_spec.sha256, backend, treatment)],
+ )
+ rows.append(
+ report_row(
+ file_spec.display_path if treatment_index == 0 else "",
+ treatment,
+ format_ratio_summary(ratio),
+ format_wall_time_change(ratio),
+ comparison_result_text(ratio),
+ )
+ )
+ tables.append(
+ ReportTableData(
+ title=f"Per-file backend wall-time change vs {baseline_backend}: {target.display_label} {backend}",
+ headers=("File", "Treatment", "Time ratio", "Wall-time change", "Result"),
+ rows=tuple(rows),
+ caption=BACKEND_WALL_TIME_CAPTION,
+ alignments=("left", "left", "right", "right", "left"),
)
- console.print(table)
+ )
+ return tuple(tables)
+
+
+def render_per_file_backend_wall_time_change(
+ console: Console,
+ cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> None:
+ for table_data in per_file_backend_wall_time_change_tables(cell_maps, targets, spec):
+ console.print(render_rich_table(table_data))
+
+
+def per_file_backend_peak_rss_change_tables(
+ rss_cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> tuple[ReportTableData, ...]:
+ baseline_backend = spec.backends[0]
+ baseline_has_rss = {
+ target: any(cell.samples for key, cell in rss_cell_maps[target].items() if key[1] == baseline_backend)
+ for target in targets
+ }
+ tables: list[ReportTableData] = []
+ for target in targets:
+ for backend in spec.backends[1:]:
+ shared_treatments = shared_backend_treatments(spec, baseline_backend, backend)
+ if not shared_treatments:
+ continue
+ candidate_has_rss = any(cell.samples for key, cell in rss_cell_maps[target].items() if key[1] == backend)
+ if not baseline_has_rss[target] and not candidate_has_rss:
+ continue
+ rows: list[tuple[str, ...]] = []
+ for file_spec in spec.files:
+ for treatment_index, treatment in enumerate(shared_treatments):
+ ratio = ratio_summary(
+ rss_cell_maps[target][(file_spec.sha256, baseline_backend, treatment)],
+ rss_cell_maps[target][(file_spec.sha256, backend, treatment)],
+ )
+ rows.append(
+ report_row(
+ file_spec.display_path if treatment_index == 0 else "",
+ treatment,
+ format_ratio_summary(ratio),
+ format_percent_change(ratio),
+ lower_is_better_result_text(ratio),
+ )
+ )
+ tables.append(
+ ReportTableData(
+ title=f"Per-file backend peak RSS change vs {baseline_backend}: {target.display_label} {backend}",
+ headers=("File", "Treatment", "RSS ratio", "RSS change", "Result"),
+ rows=tuple(rows),
+ caption=BACKEND_PEAK_RSS_CAPTION,
+ alignments=("left", "left", "right", "right", "left"),
+ )
+ )
+ return tuple(tables)
+
+
+def render_per_file_backend_peak_rss_change(
+ console: Console,
+ rss_cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> None:
+ for table_data in per_file_backend_peak_rss_change_tables(rss_cell_maps, targets, spec):
+ console.print(render_rich_table(table_data))
def render_benchmark_summary(
@@ -1804,12 +2712,88 @@ def render_benchmark_summary(
spec: BenchmarkSpec,
) -> None:
console.rule("[bold]Benchmark summary[/bold]")
+ render_backend_summary(console, cell_maps, rss_cell_maps, targets, spec)
if len(targets) == 1:
render_single_target_summary(console, cell_maps[targets[0]], rss_cell_maps[targets[0]], targets[0], spec)
else:
render_multi_target_summary(console, cell_maps, rss_cell_maps, targets, spec)
+def single_target_summary_table_data(
+ cell_map: CellMap,
+ rss_cell_map: CellMap,
+ target: ResolvedTarget,
+ spec: BenchmarkSpec,
+) -> ReportTableData:
+ rows: list[tuple[str, ...]] = []
+ for backend in spec.backends:
+ has_off_proofs = backend_has_treatment(spec, backend, "off") and backend_has_treatment(spec, backend, "proofs")
+ has_term_proofs = backend_has_treatment(spec, backend, "term") and backend_has_treatment(
+ spec, backend, "proofs"
+ )
+ if has_off_proofs:
+ rows.extend(
+ within_target_wall_summary_rows(
+ cell_map,
+ spec,
+ backend,
+ "off",
+ "proofs",
+ backend_metric_label(spec, backend, "wall proofs/off"),
+ )
+ )
+ rows.append(
+ within_target_rss_summary_row(
+ rss_cell_map,
+ spec,
+ backend,
+ "off",
+ "proofs",
+ backend_metric_label(spec, backend, "peak RSS proofs/off"),
+ )
+ )
+ elif has_term_proofs:
+ rows.extend(
+ within_target_wall_summary_rows(
+ cell_map,
+ spec,
+ backend,
+ "term",
+ "proofs",
+ backend_metric_label(spec, backend, "wall proofs/term"),
+ )
+ )
+ else:
+ rows.append(
+ report_row(
+ backend_metric_label(spec, backend, "no proof baseline"),
+ "-",
+ "-",
+ "-",
+ "-",
+ "select off and proofs",
+ )
+ )
+ if has_term_proofs:
+ rows.append(
+ within_target_rss_summary_row(
+ rss_cell_map,
+ spec,
+ backend,
+ "term",
+ "proofs",
+ backend_metric_label(spec, backend, "peak RSS proofs/term"),
+ )
+ )
+ return ReportTableData(
+ title=f"{target.display_label}: proof overhead summary",
+ headers=("Metric", "Ratio", "Change", "Worst file", "Worst ratio", "Result"),
+ rows=tuple(rows),
+ caption=PROOF_OVERHEAD_CAPTION,
+ alignments=("left", "right", "right", "left", "right", "left"),
+ )
+
+
def render_single_target_summary(
console: Console,
cell_map: CellMap,
@@ -1817,143 +2801,306 @@ def render_single_target_summary(
target: ResolvedTarget,
spec: BenchmarkSpec,
) -> None:
- table = report_table(f"{target.display_label}: proof overhead summary", caption=PROOF_OVERHEAD_CAPTION)
- table.add_column("Metric")
- table.add_column("Ratio", no_wrap=True)
- table.add_column("Change", no_wrap=True)
- table.add_column("Worst file")
- table.add_column("Worst ratio", no_wrap=True)
- table.add_column("Result")
- if "off" in spec.treatments and "proofs" in spec.treatments:
- add_within_target_wall_summary_row(table, cell_map, spec, "off", "proofs", "wall proofs/off")
- add_within_target_rss_summary_row(table, rss_cell_map, spec, "off", "proofs", "peak RSS proofs/off")
- else:
- table.add_row("no proof baseline", "-", "-", "-", "-", styled_status("descriptive", "select off and proofs"))
- if "term" in spec.treatments and "proofs" in spec.treatments:
- add_within_target_rss_summary_row(table, rss_cell_map, spec, "term", "proofs", "peak RSS proofs/term")
- console.print(table)
+ console.print(render_rich_table(single_target_summary_table_data(cell_map, rss_cell_map, target, spec)))
-def add_within_target_wall_summary_row(
- table: Table,
+def within_target_wall_summary_rows(
cell_map: CellMap,
spec: BenchmarkSpec,
+ backend: Backend,
baseline_treatment: Treatment,
candidate_treatment: Treatment,
label: str,
-) -> None:
- file_cells = treatment_file_cells(cell_map, spec, baseline_treatment, candidate_treatment)
+) -> tuple[tuple[str, ...], tuple[str, ...]]:
+ file_cells = treatment_file_cells(cell_map, spec, backend, baseline_treatment, candidate_treatment)
pairs = summary_pairs(file_cells)
summary = suite_ratio(pairs)
geometric = geometric_mean_ratio(pairs)
worst_file, worst = worst_file_ratio(ratio_pairs(file_cells))
- table.add_row(
- label,
- format_ratio_summary(summary),
- format_percent_change(summary),
- format_worst_file(worst_file),
- format_ratio_summary(worst),
- format_proof_gate_result(summary)
+ result = (
+ proof_gate_result_text(summary)
if baseline_treatment == "off" and candidate_treatment == "proofs"
- else format_comparison_result(summary),
+ else comparison_result_text(summary)
)
- table.add_row(
- f"{label} geomean",
- format_ratio_summary(geometric),
- format_percent_change(geometric),
- "-",
- "-",
- styled_status("descriptive", "descriptive"),
+ return (
+ report_row(
+ label,
+ format_ratio_summary(summary),
+ format_percent_change(summary),
+ format_worst_file(worst_file),
+ format_ratio_summary(worst),
+ result,
+ ),
+ report_row(
+ f"{label} geomean",
+ format_ratio_summary(geometric),
+ format_percent_change(geometric),
+ "-",
+ "-",
+ "descriptive",
+ ),
)
-def add_within_target_rss_summary_row(
- table: Table,
+def within_target_rss_summary_row(
rss_cell_map: CellMap,
spec: BenchmarkSpec,
+ backend: Backend,
baseline_treatment: Treatment,
candidate_treatment: Treatment,
label: str,
-) -> None:
- file_cells = treatment_file_cells(rss_cell_map, spec, baseline_treatment, candidate_treatment)
+) -> tuple[str, ...]:
+ file_cells = treatment_file_cells(rss_cell_map, spec, backend, baseline_treatment, candidate_treatment)
pairs = summary_pairs(file_cells)
summary = suite_ratio(pairs)
worst_file, worst = worst_file_ratio(ratio_pairs(file_cells))
- table.add_row(
+ return report_row(
label,
format_ratio_summary(summary),
format_percent_change(summary),
format_worst_file(worst_file),
format_ratio_summary(worst),
- format_lower_is_better_result(summary),
+ lower_is_better_result_text(summary),
)
-def render_multi_target_summary(
- console: Console,
+def multi_target_summary_tables(
cell_maps: TargetCellMaps,
rss_cell_maps: TargetCellMaps,
targets: Sequence[ResolvedTarget],
spec: BenchmarkSpec,
-) -> None:
+) -> tuple[ReportTableData, ReportTableData]:
baseline = targets[0]
- wall_table = report_table(
- f"Wall-time summary vs {baseline.display_label}",
- caption=TARGET_WALL_TIME_CAPTION,
- )
- wall_table.add_column("Target")
- wall_table.add_column("Treatment")
- wall_table.add_column("Wall-time change", no_wrap=True)
- wall_table.add_column("Geomean", no_wrap=True)
- wall_table.add_column("Worst file")
- wall_table.add_column("Worst ratio", no_wrap=True)
- wall_table.add_column("Result")
+ cells = benchmark_cells(spec)
+ wall_rows: list[tuple[str, ...]] = []
for target in targets[1:]:
- for treatment in spec.treatments:
- file_cells = target_treatment_file_cells(cell_maps[baseline], cell_maps[target], spec, treatment)
+ for cell in cells:
+ file_cells = target_treatment_file_cells(
+ cell_maps[baseline], cell_maps[target], spec, cell.backend, cell.treatment
+ )
pairs = summary_pairs(file_cells)
suite = suite_ratio(pairs)
geometric = geometric_mean_ratio(pairs)
worst_file, worst = worst_file_ratio(ratio_pairs(file_cells))
- wall_table.add_row(
- target.display_label,
- treatment,
- format_wall_time_change(suite),
- format_ratio_summary(geometric),
- format_worst_file(worst_file),
- format_ratio_summary(worst),
- format_comparison_result(suite),
+ wall_rows.append(
+ report_row(
+ target.display_label,
+ cell.backend,
+ cell.treatment,
+ format_wall_time_change(suite),
+ format_ratio_summary(geometric),
+ format_worst_file(worst_file),
+ format_ratio_summary(worst),
+ comparison_result_text(suite),
+ )
)
- console.print(wall_table)
- rss_table = report_table(
- f"Peak RSS summary vs {baseline.display_label}",
- caption=TARGET_PEAK_RSS_CAPTION,
- )
- rss_table.add_column("Target")
- rss_table.add_column("Treatment")
- rss_table.add_column("RSS change", no_wrap=True)
- rss_table.add_column("Geomean", no_wrap=True)
- rss_table.add_column("Worst file")
- rss_table.add_column("Worst ratio", no_wrap=True)
- rss_table.add_column("Result")
+ rss_rows: list[tuple[str, ...]] = []
for target in targets[1:]:
- for treatment in spec.treatments:
- file_cells = target_treatment_file_cells(rss_cell_maps[baseline], rss_cell_maps[target], spec, treatment)
+ for cell in cells:
+ file_cells = target_treatment_file_cells(
+ rss_cell_maps[baseline],
+ rss_cell_maps[target],
+ spec,
+ cell.backend,
+ cell.treatment,
+ )
pairs = summary_pairs(file_cells)
summary = suite_ratio(pairs)
geometric = geometric_mean_ratio(pairs)
worst_file, worst = worst_file_ratio(ratio_pairs(file_cells))
- rss_table.add_row(
- target.display_label,
- treatment,
- format_percent_change(summary),
- format_ratio_summary(geometric),
- format_worst_file(worst_file),
- format_ratio_summary(worst),
- format_lower_is_better_result(summary),
+ rss_rows.append(
+ report_row(
+ target.display_label,
+ cell.backend,
+ cell.treatment,
+ format_percent_change(summary),
+ format_ratio_summary(geometric),
+ format_worst_file(worst_file),
+ format_ratio_summary(worst),
+ lower_is_better_result_text(summary),
+ )
)
- console.print(rss_table)
+ return (
+ ReportTableData(
+ title=f"Wall-time summary vs {baseline.display_label}",
+ headers=(
+ "Target",
+ "Backend",
+ "Treatment",
+ "Wall-time change",
+ "Geomean",
+ "Worst file",
+ "Worst ratio",
+ "Result",
+ ),
+ rows=tuple(wall_rows),
+ caption=TARGET_WALL_TIME_CAPTION,
+ alignments=("left", "left", "left", "right", "right", "left", "right", "left"),
+ ),
+ ReportTableData(
+ title=f"Peak RSS summary vs {baseline.display_label}",
+ headers=("Target", "Backend", "Treatment", "RSS change", "Geomean", "Worst file", "Worst ratio", "Result"),
+ rows=tuple(rss_rows),
+ caption=TARGET_PEAK_RSS_CAPTION,
+ alignments=("left", "left", "left", "right", "right", "left", "right", "left"),
+ ),
+ )
+
+
+def render_multi_target_summary(
+ console: Console,
+ cell_maps: TargetCellMaps,
+ rss_cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> None:
+ for table_data in multi_target_summary_tables(cell_maps, rss_cell_maps, targets, spec):
+ console.print(render_rich_table(table_data))
+
+
+def display_backend(backend: Backend) -> str:
+ return backend_spec(backend).display_name
+
+
+def backend_summary_table(
+ cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+ metric: MetricSpec,
+) -> ReportTableData:
+ baseline_backend = spec.backends[0]
+ baseline_name = display_backend(baseline_backend)
+ include_target = len(targets) > 1
+ if len(spec.backends) == 2:
+ candidate_name = display_backend(spec.backends[1])
+ title = f"{candidate_name} vs {baseline_name} {metric.title}"
+ else:
+ candidate_name = "Candidate"
+ title = f"Backend {metric.title} summary vs {baseline_name}"
+ ratio_heading = f"{candidate_name}/{baseline_name}"
+ candidate_total_heading = f"{candidate_name} total"
+ headers: list[str] = []
+ if include_target:
+ headers.append("Target")
+ headers.extend(
+ [
+ "Backend",
+ "Mode",
+ f"{baseline_name} total",
+ candidate_total_heading,
+ ratio_heading,
+ "Change",
+ "File geomean",
+ metric.file_count_heading,
+ "Best file",
+ "Best ratio",
+ "Best result",
+ ]
+ )
+ rows: list[tuple[str, ...]] = []
+ for target in targets:
+ for backend in spec.backends[1:]:
+ for treatment in shared_backend_treatments(spec, baseline_backend, backend):
+ file_cells = backend_treatment_file_cells(
+ cell_maps[target],
+ spec,
+ baseline_backend,
+ backend,
+ treatment,
+ )
+ pairs = summary_pairs(file_cells)
+ summary = suite_ratio(pairs)
+ geometric = geometric_mean_ratio(pairs)
+ ratios = ratio_pairs(file_cells)
+ best_file, best = best_file_ratio(ratios)
+ row_values: list[object] = []
+ if include_target:
+ row_values.append(target.display_label)
+ row_values.extend(
+ [
+ backend,
+ treatment,
+ metric.format_total(suite_total_mean([baseline for baseline, _ in pairs])),
+ metric.format_total(suite_total_mean([candidate for _, candidate in pairs])),
+ format_ratio_summary(summary),
+ metric.format_change(summary),
+ format_ratio_summary(geometric),
+ format_better_file_count(count_better_files(ratios)),
+ format_worst_file(best_file),
+ format_ratio_summary(best),
+ metric.format_result(best),
+ ]
+ )
+ rows.append(report_row(*row_values))
+ right_aligned = {
+ f"{baseline_name} total",
+ candidate_total_heading,
+ ratio_heading,
+ "Change",
+ "File geomean",
+ metric.file_count_heading,
+ "Best ratio",
+ }
+ return ReportTableData(
+ title=title,
+ headers=tuple(headers),
+ rows=tuple(rows),
+ caption=metric.caption,
+ alignments=tuple("right" if header in right_aligned else "left" for header in headers),
+ )
+
+
+def backend_summary_tables(
+ cell_maps: TargetCellMaps,
+ rss_cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> tuple[ReportTableData, ...]:
+ if len(spec.backends) <= 1:
+ return ()
+ metrics = (
+ (
+ cell_maps,
+ MetricSpec(
+ title="wall time",
+ caption=BACKEND_WALL_TIME_CAPTION,
+ file_count_heading="Faster files",
+ format_total=format_seconds_value,
+ format_change=format_wall_time_change,
+ format_result=comparison_result_text,
+ ),
+ ),
+ (
+ rss_cell_maps,
+ MetricSpec(
+ title="peak RSS",
+ caption=BACKEND_PEAK_RSS_CAPTION,
+ file_count_heading="Lower-RSS files",
+ format_total=format_bytes,
+ format_change=format_percent_change,
+ format_result=lower_is_better_result_text,
+ omit_without_samples=True,
+ ),
+ ),
+ )
+ return tuple(
+ backend_summary_table(metric_cell_maps, targets, spec, metric)
+ for metric_cell_maps, metric in metrics
+ if not metric.omit_without_samples
+ or any(cell.samples for target in targets for cell in metric_cell_maps[target].values())
+ )
+
+
+def render_backend_summary(
+ console: Console,
+ cell_maps: TargetCellMaps,
+ rss_cell_maps: TargetCellMaps,
+ targets: Sequence[ResolvedTarget],
+ spec: BenchmarkSpec,
+) -> None:
+ for table_data in backend_summary_tables(cell_maps, rss_cell_maps, targets, spec):
+ console.print(render_rich_table(table_data))
def format_worst_file(file_spec: FileSpec | None) -> str:
@@ -1968,36 +3115,42 @@ def render_target_diagnostics(
spec: BenchmarkSpec,
) -> None:
render_overhead_ratios(console, cell_map, target, spec)
+ console.print(render_rich_table(target_wall_time_table_data(cell_map, target, spec)))
+ render_peak_rss_diagnostics(console, rss_cell_map, target, spec)
- means_table = report_table(
- f"{target.display_label}: per-file wall time",
- caption="Within-target wall-time estimates. These are not target-vs-baseline ratios.",
- )
- means_table.add_column("File")
- for treatment in spec.treatments:
- means_table.add_column(treatment, no_wrap=True)
- means_rows: list[tuple[list[str], str]] = []
+
+def target_wall_time_table_data(cell_map: CellMap, target: ResolvedTarget, spec: BenchmarkSpec) -> ReportTableData:
+ cells = benchmark_cells(spec)
+ headers = ["File", *(cell_label(spec, cell.backend, cell.treatment) for cell in cells)]
+ rows_with_issue: list[tuple[list[str], str]] = []
has_mean_issues = False
for file_spec in spec.files:
issue_parts: list[str] = []
row_values = [file_spec.display_path]
- for treatment in spec.treatments:
- cell = cell_map[(file_spec.sha256, treatment)]
- row_values.append(format_seconds_summary(cell))
- if cell.issue is not None:
- issue_parts.append(f"{treatment}: {cell.issue}")
+ for cell in cells:
+ summary = cell_map[(file_spec.sha256, cell.backend, cell.treatment)]
+ row_values.append(format_seconds_summary(summary))
+ if summary.issue is not None:
+ issue_parts.append(f"{cell_label(spec, cell.backend, cell.treatment)}: {summary.issue}")
issue_text = "; ".join(issue_parts)
has_mean_issues = has_mean_issues or bool(issue_text)
- means_rows.append((row_values, issue_text))
+ rows_with_issue.append((row_values, issue_text))
if has_mean_issues:
- means_table.add_column("Issue")
- for row_values, issue_text in means_rows:
+ headers.append("Issue")
+ rows: list[tuple[str, ...]] = []
+ for row_values, issue_text in rows_with_issue:
if has_mean_issues:
row_values.append(issue_text)
- means_table.add_row(*row_values)
- console.print(means_table)
-
- render_peak_rss_diagnostics(console, rss_cell_map, target, spec)
+ rows.append(tuple(row_values))
+ return ReportTableData(
+ title=f"{target.display_label}: per-file wall time",
+ headers=tuple(headers),
+ rows=tuple(rows),
+ caption="Within-target wall-time estimates. These are not target-vs-baseline ratios.",
+ alignments=tuple(
+ "left" if index == 0 or header == "Issue" else "right" for index, header in enumerate(headers)
+ ),
+ )
def render_overhead_ratios(
@@ -2006,26 +3159,39 @@ def render_overhead_ratios(
target: ResolvedTarget,
spec: BenchmarkSpec,
) -> None:
- ratio_columns = ratio_specs(spec.treatments)
- if not ratio_columns:
- return
- ratio_table = report_table(
- f"{target.display_label}: overhead ratios",
- caption="Within-target treatment ratios. These are not target-vs-baseline wall-time change.",
- )
- ratio_table.add_column("File")
- for _, _, ratio_name in ratio_columns:
- ratio_table.add_column(ratio_name, no_wrap=True)
+ table_data = overhead_ratios_table_data(cell_map, target, spec)
+ if table_data is not None:
+ console.print(render_rich_table(table_data))
+
+
+def overhead_ratios_table_data(
+ cell_map: CellMap, target: ResolvedTarget, spec: BenchmarkSpec
+) -> ReportTableData | None:
+ ratio_columns = {backend: ratio_specs_for_backend(spec, backend) for backend in spec.backends}
+ if not any(ratio_columns.values()):
+ return None
+ headers = ["File"]
+ for backend in spec.backends:
+ for _, _, ratio_name in ratio_columns[backend]:
+ headers.append(backend_metric_label(spec, backend, ratio_name))
+ rows: list[tuple[str, ...]] = []
for file_spec in spec.files:
row_values = [file_spec.display_path]
- for baseline_treatment, candidate_treatment, _ in ratio_columns:
- ratio = ratio_summary(
- cell_map[(file_spec.sha256, baseline_treatment)],
- cell_map[(file_spec.sha256, candidate_treatment)],
- )
- row_values.append(format_ratio_summary(ratio))
- ratio_table.add_row(*row_values)
- console.print(ratio_table)
+ for backend in spec.backends:
+ for baseline_treatment, candidate_treatment, _ in ratio_columns[backend]:
+ ratio = ratio_summary(
+ cell_map[(file_spec.sha256, backend, baseline_treatment)],
+ cell_map[(file_spec.sha256, backend, candidate_treatment)],
+ )
+ row_values.append(format_ratio_summary(ratio))
+ rows.append(tuple(row_values))
+ return ReportTableData(
+ title=f"{target.display_label}: overhead ratios",
+ headers=tuple(headers),
+ rows=tuple(rows),
+ caption="Within-target treatment ratios. These are not target-vs-baseline wall-time change.",
+ alignments=tuple("left" if index == 0 else "right" for index, _ in enumerate(headers)),
+ )
def render_peak_rss_diagnostics(
@@ -2034,35 +3200,49 @@ def render_peak_rss_diagnostics(
target: ResolvedTarget,
spec: BenchmarkSpec,
) -> None:
+ table_data = target_peak_rss_table_data(rss_cell_map, target, spec)
+ if table_data is not None:
+ console.print(render_rich_table(table_data))
+
+
+def target_peak_rss_table_data(
+ rss_cell_map: CellMap,
+ target: ResolvedTarget,
+ spec: BenchmarkSpec,
+) -> ReportTableData | None:
if not any(cell.samples for cell in rss_cell_map.values()):
- return
- rss_table = report_table(
- f"{target.display_label}: per-file peak RSS",
- caption="Within-target peak resident set size estimates. These are separate from wall-time ratios.",
- )
- rss_table.add_column("File")
- for treatment in spec.treatments:
- rss_table.add_column(treatment, no_wrap=True)
+ return None
+ cells = benchmark_cells(spec)
+ headers = ["File", *(cell_label(spec, cell.backend, cell.treatment) for cell in cells)]
rss_rows: list[tuple[list[str], str]] = []
has_rss_issues = False
for file_spec in spec.files:
issue_parts: list[str] = []
row_values = [file_spec.display_path]
- for treatment in spec.treatments:
- cell = rss_cell_map[(file_spec.sha256, treatment)]
- row_values.append(format_bytes_summary(cell))
- if cell.issue is not None:
- issue_parts.append(f"{treatment}: {cell.issue}")
+ for cell in cells:
+ summary = rss_cell_map[(file_spec.sha256, cell.backend, cell.treatment)]
+ row_values.append(format_bytes_summary(summary))
+ if summary.issue is not None:
+ issue_parts.append(f"{cell_label(spec, cell.backend, cell.treatment)}: {summary.issue}")
issue_text = "; ".join(issue_parts)
has_rss_issues = has_rss_issues or bool(issue_text)
rss_rows.append((row_values, issue_text))
if has_rss_issues:
- rss_table.add_column("Issue")
+ headers.append("Issue")
+ rows: list[tuple[str, ...]] = []
for row_values, issue_text in rss_rows:
if has_rss_issues:
row_values.append(issue_text)
- rss_table.add_row(*row_values)
- console.print(rss_table)
+ rows.append(tuple(row_values))
+ return ReportTableData(
+ title=f"{target.display_label}: per-file peak RSS",
+ headers=tuple(headers),
+ rows=tuple(rows),
+ caption="Within-target peak resident set size estimates. These are separate from wall-time ratios.",
+ alignments=tuple(
+ "left" if index == 0 or header == "Issue" else "right" for index, header in enumerate(headers)
+ ),
+ )
def ratio_specs(treatments: Sequence[Treatment]) -> tuple[tuple[Treatment, Treatment, str], ...]:
@@ -2076,14 +3256,139 @@ def ratio_specs(treatments: Sequence[Treatment]) -> tuple[tuple[Treatment, Treat
return tuple(specs)
+def ratio_specs_for_backend(
+ spec: BenchmarkSpec,
+ backend: Backend,
+) -> tuple[tuple[Treatment, Treatment, str], ...]:
+ return tuple(
+ ratio_spec
+ for ratio_spec in ratio_specs(spec.treatments)
+ if backend_has_treatment(spec, backend, ratio_spec[0]) and backend_has_treatment(spec, backend, ratio_spec[1])
+ )
+
+
+def run_profile(args: argparse.Namespace, output: RunnerOutput, invocation_cwd: Path, repo_root: Path) -> None:
+ request = resolve_profile_request(args, invocation_cwd)
+ target = resolve_profile_target(request.target_request, request.backend, invocation_cwd, repo_root, output)
+ if target.binary_path is None:
+ raise ValueError(f"target {target.display_label} needs a profiling binary")
+ checkout_path = Path(target.row.path)
+ workload = workload_command(target.binary_path, request.file, request.backend, request.treatment)
+ artifact = profile_cache_path(
+ request.profiles_dir,
+ target.binary_sha256,
+ request.file.sha256,
+ request.backend,
+ request.treatment,
+ request.mode,
+ )
+ profile: dict[str, Any] | None = None
+ cache_status: Literal["hit", "recorded"] = "recorded"
+ if profile_cache_hit(artifact) and not request.force_run:
+ try:
+ profile = samply_analysis.read_artifact(artifact)
+ except ValueError as error:
+ output.console.print(f"[yellow]warning:[/yellow] ignoring invalid profile cache entry: {error}")
+ else:
+ cache_status = "hit"
+ output.console.print(f"[bold]Profile cache hit[/bold] {artifact}")
+
+ if profile is None:
+ iterations = request.mode.iterations
+ if iterations is None:
+ assert request.mode.profile_seconds is not None
+ output.console.print(
+ f"[bold]Calibrating[/bold] {request.file.display_path} "
+ f"{request.backend}/{request.treatment} for {request.mode.profile_seconds}s"
+ )
+ calibration = run_command(workload, checkout_path, request.timeout_sec)
+ if calibration.status != "success" or calibration.timing.wall_sec is None:
+ detail = calibration.error.message if calibration.error is not None else calibration.status
+ raise ValueError(f"profile calibration failed: {detail}")
+ iterations, capped = calculate_profile_iterations(
+ calibration.timing.wall_sec,
+ request.mode.profile_seconds,
+ )
+ output.console.print(
+ f" calibration: {calibration.timing.wall_sec:.3f}s; recording {iterations} Samply iteration(s)"
+ )
+ if capped:
+ output.console.print(
+ "[yellow]warning:[/yellow] maximum profile iterations reached; "
+ "the profile may be shorter than the requested duration"
+ )
+
+ assert iterations is not None
+ name = profile_name(
+ request.file,
+ request.backend,
+ request.treatment,
+ request.mode,
+ iterations,
+ target.binary_sha256,
+ )
+ output.console.print(f"[bold]Recording profile[/bold] {artifact}")
+ profile = run_samply_record(
+ artifact=artifact,
+ name=name,
+ iterations=iterations,
+ workload=workload,
+ checkout_path=checkout_path,
+ timeout_sec=request.timeout_sec,
+ )
+ output.console.print(f"[bold]Profile written[/bold] {artifact}")
+
+ if request.show_summary:
+ display_artifact = profile_display_path(artifact, invocation_cwd)
+ summary: samply_analysis.ProfileCpuSummary | None = None
+ if sys.platform == "darwin":
+ try:
+ summary = samply_analysis.summarize(profile, target.binary_path)
+ except ValueError as error:
+ output.console.print(f"[yellow]warning:[/yellow] CPU profile summary unavailable: {error}")
+ if summary is not None:
+ for warning in summary.warnings:
+ output.console.print(f"[yellow]warning:[/yellow] {warning}")
+ else:
+ output.console.print(
+ "[yellow]warning:[/yellow] CPU profile summaries are currently available on macOS only; "
+ "the Samply artifact was created normally."
+ )
+ report = samply_analysis.ProfileReport(
+ artifact=display_artifact,
+ cache_status=cache_status,
+ workload=request.file.display_path,
+ backend=request.backend,
+ treatment=request.treatment,
+ top=request.top,
+ cpu_summary=summary,
+ )
+ if request.output_format == "markdown":
+ rendered = samply_analysis.render_markdown(report)
+ sys.stdout.write(rendered + "\n")
+ sys.stdout.flush()
+ else:
+ samply_analysis.render_rich(output.console, report)
+ else:
+ sys.stdout.write(str(artifact.resolve()) + "\n")
+ sys.stdout.flush()
+ if request.open_after:
+ open_samply_profile(artifact, checkout_path)
+
+
def main(argv: Sequence[str] | None = None) -> int:
- args = parse_args(sys.argv[1:] if argv is None else argv)
+ raw_argv = tuple(sys.argv[1:] if argv is None else argv)
+ args = parse_args(raw_argv)
output = RunnerOutput()
try:
script_root = Path(__file__).resolve().parent
- treatments = parse_treatments(args.treatments)
invocation_cwd = Path.cwd()
repo_root = git_root_for_path(script_root)
+ if args.command == "profile":
+ run_profile(args, output, invocation_cwd, repo_root)
+ return 0
+ backends = parse_backends(args.backend)
+ treatments = parse_treatments(args.treatments)
report_destination = resolve_report_destination(args.report, invocation_cwd)
files = resolve_files(args.files, invocation_cwd)
spec = BenchmarkSpec(
@@ -2091,6 +3396,7 @@ def main(argv: Sequence[str] | None = None) -> int:
treatments=treatments,
rounds=args.rounds,
timeout_sec=args.timeout_sec,
+ backends=backends,
)
validate_spec(spec)
target_specs = args.target if args.target is not None else ["."]
@@ -2119,14 +3425,17 @@ def main(argv: Sequence[str] | None = None) -> int:
emit_collection_plan(output, plan, estimate_model)
collection = collect_rows(rows, report_destination, plan, spec, output, estimate_model)
rows = collection.rows
- render_report(
- output.console,
- report_destination,
- rows,
- targets,
- spec,
- )
- except (FileNotFoundError, ValueError, subprocess.CalledProcessError) as error:
+ if args.format == "markdown":
+ sys.stdout.write(render_markdown_report(report_destination, rows, targets, spec, raw_argv) + "\n")
+ else:
+ render_report(
+ output.console,
+ report_destination,
+ rows,
+ targets,
+ spec,
+ )
+ except (FileNotFoundError, ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
output.print_error(error)
return 2
return 0
diff --git a/egglog-experimental/Cargo.toml b/egglog-experimental/Cargo.toml
index 739105f..4c25352 100644
--- a/egglog-experimental/Cargo.toml
+++ b/egglog-experimental/Cargo.toml
@@ -19,11 +19,13 @@ name = "files"
[features]
default = ["bin"]
bin = ["egglog/bin"]
+dd-backend = ["dep:egglog-experimental-dd"]
[dependencies]
egglog = { workspace = true }
egglog-ast = { workspace = true }
egglog-reports = { workspace = true }
+egglog-experimental-dd = { path = "dd", optional = true }
num = "0.4.3"
lazy_static = "1.4"
diff --git a/egglog-experimental/dd/Cargo.toml b/egglog-experimental/dd/Cargo.toml
new file mode 100644
index 0000000..616b3d6
--- /dev/null
+++ b/egglog-experimental/dd/Cargo.toml
@@ -0,0 +1,38 @@
+# An experimental differential-dataflow backend for egglog. It depends only on
+# the public backend SPI plus the neutral shared rule AST and core relations,
+# so it can serve as an out-of-tree backend example.
+[package]
+name = "egglog-experimental-dd"
+version = "0.1.0"
+edition = "2021"
+description = "An experimental differential-dataflow-backed egglog Backend, as an out-of-tree example of the egglog-backend-trait SPI."
+license = "MIT"
+publish = false
+
+[dependencies]
+anyhow = "1"
+egglog-backend-trait = { path = "../../egglog/egglog-backend-trait" }
+egglog-ast = { path = "../../egglog/egglog-ast" }
+egglog-core-relations = { path = "../../egglog/core-relations" }
+egglog-numeric-id = { path = "../../egglog/numeric-id" }
+hashbrown = "0.16"
+log = "0.4"
+# Raw differential-dataflow + timely, used in-process by `dd_native.rs`.
+differential-dataflow = "=0.24.0"
+timely = "=0.30.0"
+serde = "1"
+
+[[test]]
+# Parity harness over egglog's .egg corpus: checks the DD backend against
+# egglog's OWN shared snapshots (`egglog/tests/snapshots/files__shared_snapshot_
+# *.snap`), so egglog is the single source of truth. Custom libtest-mimic main.
+name = "files"
+harness = false
+
+[dev-dependencies]
+# The frontend, so the integration tests can run real `.egg` files on the
+# Differential Dataflow backend via `egglog::EGraph::with_backend(..)`.
+egglog = { path = "../../egglog", default-features = false }
+egglog-experimental = { path = "..", default-features = false }
+insta = "1.40"
+libtest-mimic = "0.8"
diff --git a/egglog-experimental/dd/src/compile.rs b/egglog-experimental/dd/src/compile.rs
new file mode 100644
index 0000000..289052f
--- /dev/null
+++ b/egglog-experimental/dd/src/compile.rs
@@ -0,0 +1,54 @@
+//! DD-specific physical values and merge plans.
+
+use egglog_ast::core::GenericAtomTerm;
+use egglog_backend_trait::{ExternalFunctionId, FunctionId, ReadMode, RuleValue, RuleVar};
+use egglog_numeric_id::NumericId;
+
+/// Variable-width row stored in the host-side relation mirror.
+pub type Row = Box<[u32]>;
+
+/// How a function resolves a functional-dependency conflict.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MergeMode {
+ Relation,
+ Old,
+ New,
+ Min,
+ Computed,
+}
+
+/// An evaluatable computed merge expression.
+#[derive(Clone, Debug)]
+pub enum MergeTree {
+ Old,
+ New,
+ Const(u32),
+ Prim(ExternalFunctionId, Vec),
+ Func(FunctionId, Vec),
+}
+
+/// A physical column operand used by a DD join plan.
+#[derive(Clone, Debug)]
+pub enum Slot {
+ Var(u32),
+ Const(u32),
+}
+
+impl Slot {
+ pub fn from_term(term: &GenericAtomTerm) -> Result {
+ match term {
+ GenericAtomTerm::Var(_, variable) => Ok(Self::Var(variable.id)),
+ GenericAtomTerm::Literal(_, value) => Ok(Self::Const(value.value.rep())),
+ GenericAtomTerm::Global(..) => {
+ Err("globals must be desugared before DD rule lowering".into())
+ }
+ }
+ }
+}
+
+/// A distinct DD input stream for one table read view.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct ReadKey {
+ pub func: FunctionId,
+ pub mode: ReadMode,
+}
diff --git a/egglog-experimental/dd/src/dd_native.rs b/egglog-experimental/dd/src/dd_native.rs
new file mode 100644
index 0000000..0ac38a7
--- /dev/null
+++ b/egglog-experimental/dd/src/dd_native.rs
@@ -0,0 +1,991 @@
+//! In-process, build-once, epoch-driven incremental body join on RAW
+//! `differential-dataflow` + `timely`.
+//!
+//! This is the only join path for the Differential Dataflow backend (driven by
+//! [`crate::interpret::run_iteration`]); there is no host nested-loop fallback.
+//! Unsupported shapes are reported to the caller; see [`plan_join`].
+//!
+//! ## The join: one fused worker per ruleset
+//!
+//! [`FusedDdJoin`] is the path the interpreter drives: ONE shared timely
+//! `Worker` hosts ONE dataflow for a whole RULESET, every distinct body relation
+//! is a single SHARED input `Collection`, and each rule is a join sub-stream
+//! reading those shared collections. It feeds only signed DELTAS into
+//! never-cleared InputSessions, so the arrangements persist across epochs =
+//! incremental.
+//!
+//! ## The base architecture
+//!
+//! Each ruleset owns one dataflow in a single-threaded timely [`Worker`] that is
+//! stepped across host calls. Every body relation view has one shared
+//! [`InputSession`], while each atom-bearing rule lowers to a left-deep join
+//! subgraph. Table constraints run in the dataflow; body primitives run later
+//! in the host interpreter. Binding deltas flow through `.inspect_batch` into
+//! per-rule capture buffers.
+//!
+//! The host feeds only per-relation signed deltas (`+1` insert, `-1` retract)
+//! into the input sessions, advances the timely timestamp for each nonempty
+//! delta batch, drives the worker to that epoch's frontier, and drains the
+//! output binding deltas. One egglog iteration may submit a removal batch and
+//! then an insertion batch to preserve reinsertion freshness. The
+//! InputSessions are NEVER cleared — the DD arrangements persist across epochs,
+//! which is what makes the join genuinely incremental (epoch K does only
+//! delta·integral work, not a full recompute) — the whole point of the design.
+//!
+//! ## Fixpoint structure
+//!
+//! We use EXTERNAL epoch-drive (the host loop advances epochs and feeds head
+//! outputs back as the next epoch's inputs), NOT an in-dataflow `iterate()`
+//! scope. This matches egglog's bounded `(run N)` fire->rebuild->repeat model and
+//! sidesteps DD `iterate()`'s monotonicity constraints under retraction (a
+//! rebuild RETRACTS non-canonical rows, which `iterate()` cannot express
+//! cleanly). The dataflow itself is NON-recursive: one epoch = one bounded hop.
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use anyhow::{anyhow, bail, Result};
+use differential_dataflow::input::InputSession;
+use egglog_ast::core::{GenericAtom, GenericAtomTerm, GenericCoreAction};
+use egglog_backend_trait::{RuleActionCall, RuleBodyCall, RuleSpec, RuleValue, RuleVar};
+use hashbrown::HashMap;
+use timely::communication::allocator::thread::Thread;
+use timely::communication::allocator::Allocator;
+use timely::dataflow::operators::probe::Handle as ProbeHandle;
+use timely::dataflow::operators::{Inspect, Probe};
+use timely::worker::Worker;
+use timely::WorkerConfig;
+
+use crate::compile::{ReadKey, Slot};
+
+/// A signed `(row, weight)` delta for one relation (`+1` inserted, `-1`
+/// retracted), with rows as plain `Vec`.
+type SignedDelta = Vec<(Vec, isize)>;
+/// Per-relation-view input deltas fed into one [`FusedDdJoin::step`].
+type DeltaMap = HashMap;
+/// One `step`'s captured output deltas, parallel to the fused join's rule list.
+type StepOutput = Vec;
+/// A per-rule output-capture buffer shared with the DD closure (fixed-width
+/// [`Row`] rows).
+type CaptureBuf = Rc>>;
+
+/// Fixed binding-row width (DD `Data` needs a `Sized + Ord + Hash` type; an
+/// array gives us that). Set to 48 to cover the widest live-variable frontier
+/// in the backend corpus: `luminal-llama`'s `@rebuild_rule34` uses 35 distinct
+/// body vars in a wide congruence-closure rebuild. A larger live frontier is
+/// reported as a row-width-cap wall. Raising `W` extends coverage at a cost of
+/// `W * 4` bytes per relation or binding row.
+pub const W: usize = 48;
+
+/// A fixed-width relation or binding row flowing through the DD dataflow. Input
+/// rows store relation columns in the low slots. Intermediate binding columns
+/// are assigned by the current `ProjectionPlan` stage, and captured outputs
+/// repack surviving variables into the low slots in [`JoinPlan::var_order`].
+///
+/// A NEWTYPE over `[u32; W]` (rather than the bare array) because timely's
+/// `ExchangeData` bound required by DD joins is
+/// `Serialize + Deserialize`, and `serde` only derives those for arrays up to
+/// length 32. The hand-written serde impl (serialize as a fixed-length seq of
+/// `W` `u32`s) lifts that cap so `W` can exceed 32 (the corpus needs 35). All
+/// other derives (`Ord`/`Hash`/`Clone`/`Copy`) are auto for any array size.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub struct Row([u32; W]);
+
+impl std::ops::Index for Row {
+ type Output = u32;
+ #[inline]
+ fn index(&self, i: usize) -> &u32 {
+ &self.0[i]
+ }
+}
+
+impl std::ops::IndexMut for Row {
+ #[inline]
+ fn index_mut(&mut self, i: usize) -> &mut u32 {
+ &mut self.0[i]
+ }
+}
+
+impl serde::Serialize for Row {
+ fn serialize(&self, s: S) -> Result {
+ use serde::ser::SerializeTuple;
+ // Fixed-length tuple of W u32s — bincode-friendly, no length prefix
+ // needed (the deserializer knows W). Sidesteps serde's 32-array cap.
+ let mut t = s.serialize_tuple(W)?;
+ for v in &self.0 {
+ t.serialize_element(v)?;
+ }
+ t.end()
+ }
+}
+
+impl<'de> serde::Deserialize<'de> for Row {
+ fn deserialize>(d: D) -> Result {
+ struct RowVisitor;
+ impl<'de> serde::de::Visitor<'de> for RowVisitor {
+ type Value = Row;
+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ write!(f, "a tuple of {W} u32s")
+ }
+ fn visit_seq>(self, mut seq: A) -> Result {
+ let mut a = [0u32; W];
+ for (i, slot) in a.iter_mut().enumerate() {
+ *slot = seq
+ .next_element()?
+ .ok_or_else(|| serde::de::Error::invalid_length(i, &self))?;
+ }
+ Ok(Row(a))
+ }
+ }
+ d.deserialize_tuple(W, RowVisitor)
+ }
+}
+
+impl Default for Row {
+ fn default() -> Self {
+ Row([0; W])
+ }
+}
+
+/// A planned DD join: table atoms plus a bounded live-variable layout.
+pub struct JoinPlan {
+ /// Body table atoms in emission order.
+ atoms: Vec,
+ /// Per-step column allocation for variables live at each join stage.
+ projection: ProjectionPlan,
+}
+
+struct PlanAtom {
+ read_key: ReadKey,
+ slots: Vec,
+}
+
+impl JoinPlan {
+ /// The variable id at each captured binding-row column, in column order.
+ #[cfg(test)]
+ pub fn var_order(&self) -> Vec {
+ self.projection.head_vars.clone()
+ }
+}
+
+/// Build the join plan for `rule`, or `Err(reason)` if the DD dataflow cannot
+/// support its shape.
+/// Supported rules have one or more table atoms, atom arity at most [`W`], and
+/// no more than [`W`] simultaneously live body variables. Body primitives are
+/// evaluated later by the host interpreter, so they do not become DD operators.
+pub fn plan_join(rule: &RuleSpec) -> Result {
+ use hashbrown::HashSet;
+
+ let mut body_vars = HashSet::new();
+ let mut atoms: Vec = Vec::new();
+
+ for atom in &rule.core.body.atoms {
+ match atom.head {
+ RuleBodyCall::Table { id, read } => {
+ if atom.args.len() > W {
+ return Err(format!("atom arity {} > W {}", atom.args.len(), W));
+ }
+ let slots = atom
+ .args
+ .iter()
+ .map(Slot::from_term)
+ .collect::, _>>()?;
+ for s in &slots {
+ if let Slot::Var(v) = s {
+ body_vars.insert(*v);
+ }
+ }
+ atoms.push(PlanAtom {
+ read_key: ReadKey {
+ func: id,
+ mode: read,
+ },
+ slots,
+ });
+ }
+ // The host replays body primitives over each completed table
+ // binding. Their variables remain live through the projection.
+ RuleBodyCall::Primitive { .. } => {}
+ }
+ }
+
+ if atoms.is_empty() {
+ return Err("no body table atoms (atom-less rule)".to_string());
+ }
+ let projection = build_projection(&atoms, &rule.core.head.0, &rule.core.body.atoms)
+ .ok_or_else(|| {
+ format!(
+ "live body-variable frontier exceeds W {W} ({} distinct body variables)",
+ body_vars.len()
+ )
+ })?;
+ Ok(JoinPlan { atoms, projection })
+}
+
+/// Per-step binding columns produced by linear-scan allocation over body-atom
+/// liveness. Reusing dead variables' columns lets some rules with more than
+/// [`W`] total variables fit in a fixed-width [`Row`].
+#[derive(Clone, Debug)]
+struct ProjectionPlan {
+ /// `step_col[i]` maps each variable LIVE during step `i` (atom `i`'s join) to
+ /// its binding-row column at that step. A var's column is stable from its
+ /// birth step to its death step but may differ across non-overlapping vars.
+ step_col: Vec>,
+ /// The surviving (head/body-prim-relevant) variables and their FINAL columns,
+ /// in a deterministic order. Drives the reduced head-scatter `var_order`.
+ head_vars: Vec,
+ /// Final column of each surviving var (parallel to `head_vars`).
+ head_cols: Vec,
+}
+
+/// Build a column-reusing layout for the body atoms, or `None` if the reused
+/// frontier still exceeds `W`. Liveness is first-use..last-use over the EMITTED
+/// atom order, EXTENDED so any variable read by the head or a body prim stays
+/// live to the end (it must survive into the captured row). Linear-scan slot
+/// assignment: a column freed by a dead var is reused by a later var's birth.
+fn build_projection(
+ atoms: &[PlanAtom],
+ head: &[GenericCoreAction],
+ body: &[GenericAtom],
+) -> Option {
+ use hashbrown::HashSet;
+ let n = atoms.len();
+
+ // Variables the head / body prims read: these must survive to the end.
+ let mut survivor: HashSet = HashSet::new();
+ collect_head_vars(head, &mut survivor);
+ for atom in body {
+ if matches!(atom.head, RuleBodyCall::Primitive { .. }) {
+ for term in &atom.args {
+ if let GenericAtomTerm::Var(_, variable) = term {
+ survivor.insert(variable.id);
+ }
+ }
+ }
+ }
+
+ // first[v]/last[v] = first/last atom index where v appears. Survivors get
+ // last = n-1 (live to the final step) so they are never freed mid-chain.
+ let mut first: HashMap = HashMap::new();
+ let mut last: HashMap = HashMap::new();
+ for (i, a) in atoms.iter().enumerate() {
+ for s in &a.slots {
+ if let Slot::Var(v) = s {
+ first.entry(*v).or_insert(i);
+ last.insert(*v, i);
+ }
+ }
+ }
+ for v in &survivor {
+ if let Some(l) = last.get_mut(v) {
+ *l = n - 1;
+ }
+ }
+
+ // Vars born / dying at each step (in deterministic var-id order so the layout
+ // is reproducible).
+ let mut births: Vec> = vec![Vec::new(); n];
+ let mut deaths: Vec> = vec![Vec::new(); n];
+ let mut vars: Vec = first.keys().copied().collect();
+ vars.sort_unstable();
+ for v in vars {
+ births[first[&v]].push(v);
+ deaths[last[&v]].push(v);
+ }
+
+ // Linear-scan register allocation. `col_of` is the current column of each
+ // live var; `free` is a stack of reusable columns (lowest first, so the
+ // layout is deterministic); `next` is the high-water column allocator.
+ let mut col_of: HashMap = HashMap::new();
+ let mut free: Vec = Vec::new();
+ let mut next: usize = 0;
+ let mut step_col: Vec> = Vec::with_capacity(n);
+ for i in 0..n {
+ // Births first (so this atom's fresh vars get columns before we snapshot).
+ for &v in &births[i] {
+ let c = if let Some(c) = free.pop() {
+ c
+ } else {
+ let c = next;
+ next += 1;
+ c
+ };
+ col_of.insert(v, c);
+ }
+ if col_of.len() > W {
+ return None;
+ }
+ // Snapshot the layout AT this step (after births, before deaths) — every
+ // var the atom touches plus all still-live carried vars are present.
+ step_col.push(col_of.clone());
+ // Deaths free their columns for reuse by later births.
+ for &v in &deaths[i] {
+ if let Some(c) = col_of.remove(&v) {
+ free.push(c);
+ }
+ // freeing low-first keeps assignment deterministic
+ free.sort_unstable_by(|a, b| b.cmp(a));
+ }
+ }
+
+ // Surviving vars + their FINAL columns (the columns they hold at the last
+ // step, where they are guaranteed live). Deterministic order by var id.
+ let final_layout = &step_col[n - 1];
+ let mut head_vars: Vec = survivor
+ .into_iter()
+ .filter(|v| final_layout.contains_key(v))
+ .collect();
+ head_vars.sort_unstable();
+ let head_cols: Vec = head_vars.iter().map(|v| final_layout[v]).collect();
+
+ Some(ProjectionPlan {
+ step_col,
+ head_vars,
+ head_cols,
+ })
+}
+
+/// Collect every variable a head action references into `out`.
+fn collect_head_vars(
+ head: &[GenericCoreAction],
+ out: &mut hashbrown::HashSet,
+) {
+ let add = |term: &GenericAtomTerm, out: &mut hashbrown::HashSet| {
+ if let GenericAtomTerm::Var(_, variable) = term {
+ out.insert(variable.id);
+ }
+ };
+ for action in head {
+ match action {
+ GenericCoreAction::Let(_, variable, _, arguments) => {
+ for argument in arguments {
+ add(argument, out);
+ }
+ out.insert(variable.id);
+ }
+ GenericCoreAction::LetAtomTerm(_, variable, term) => {
+ add(term, out);
+ out.insert(variable.id);
+ }
+ GenericCoreAction::Set(_, _, arguments, value) => {
+ for argument in arguments {
+ add(argument, out);
+ }
+ add(value, out);
+ }
+ GenericCoreAction::Change(_, _, _, arguments) => {
+ for argument in arguments {
+ add(argument, out);
+ }
+ }
+ GenericCoreAction::Union(_, lhs, rhs) => {
+ add(lhs, out);
+ add(rhs, out);
+ }
+ GenericCoreAction::Panic(..) => {}
+ }
+ }
+}
+
+/// A fused, delta-fed body join for a WHOLE ruleset on a single shared timely
+/// `Worker`. Built once via [`FusedDdJoin::build`]; driven across epochs via
+/// [`FusedDdJoin::step`] with a SINGLE `worker.step_while` per call.
+pub struct FusedDdJoin {
+ worker: Worker,
+ /// One shared input session per DISTINCT body relation read view across all
+ /// rules.
+ inputs: HashMap>,
+ /// Single probe on all rule outputs (they share the dataflow scope, so one
+ /// probe gates the whole epoch's fixpoint).
+ probe: ProbeHandle,
+ /// Distinct relation read views across the whole ruleset, in first-use
+ /// order. This is the authoritative host feed order for the fused worker.
+ reads: Vec,
+ /// The fused rules in caller-supplied build order. The sorted rule-index list
+ /// identifies the ruleset cache entry but does not reorder these outputs.
+ rules: Vec,
+ /// Current epoch (monotonic; advanced once per [`step`]).
+ epoch: u32,
+}
+
+/// One rule's lowering inside a [`FusedDdJoin`]: its rule index (for routing
+/// bindings to its head), its per-epoch output capture buffer, and the variable
+/// order used to unpack captured rows.
+struct FusedRule {
+ idx: usize,
+ /// This rule's per-epoch output binding-delta capture (`inspect_batch`
+ /// appends `(row, weight)`; drained by [`FusedDdJoin::step`]).
+ captured: CaptureBuf,
+ /// Variable ids packed into each captured row, in capture-column order.
+ var_order: Vec,
+}
+
+impl FusedDdJoin {
+ /// Build ONE worker + ONE dataflow for the whole ruleset. `plans` pairs each
+ /// rule's index with its [`JoinPlan`], in the order they should fire. Every
+ /// rule — congruence, user, and canonicalization — runs through the same
+ /// general fused join.
+ pub fn build(plans: &[(usize, JoinPlan)]) -> Result {
+ if plans.is_empty() {
+ bail!("cannot build a fused DD join without any rule plans");
+ }
+ let alloc = Allocator::Thread(Thread::default());
+ let mut worker = Worker::new(
+ WorkerConfig::default(),
+ alloc,
+ Some(std::time::Instant::now()),
+ );
+
+ // Distinct body relation read views across all rules → one shared input
+ // each.
+ let mut reads: Vec = Vec::new();
+ for (_, plan) in plans {
+ for a in &plan.atoms {
+ if !reads.contains(&a.read_key) {
+ reads.push(a.read_key);
+ }
+ }
+ }
+
+ // Owned per-rule plan snapshots so the `move` dataflow closure is 'static.
+ struct RulePlan {
+ idx: usize,
+ atoms: Vec>,
+ atom_reads: Vec,
+ var_order: Vec,
+ projection: ProjectionPlan,
+ }
+ let rule_plans: Vec = plans
+ .iter()
+ .map(|(idx, plan)| {
+ let atom_reads: Vec = plan.atoms.iter().map(|a| a.read_key).collect();
+ RulePlan {
+ idx: *idx,
+ atoms: plan.atoms.iter().map(|a| a.slots.clone()).collect(),
+ atom_reads,
+ var_order: plan.projection.head_vars.clone(),
+ projection: plan.projection.clone(),
+ }
+ })
+ .collect();
+
+ let probe: ProbeHandle = ProbeHandle::new();
+ let probe_in = probe.clone();
+ // Per-rule capture buffers, allocated outside the closure so we can keep a
+ // clone here and route each rule's output to its head after `step`.
+ let captures: Vec = rule_plans
+ .iter()
+ .map(|_| Rc::new(RefCell::new(Vec::new())))
+ .collect();
+ let captures_in = captures.clone();
+ let reads_in = reads.clone();
+ // The per-rule metadata `FusedRule` needs (kept here; the closure consumes
+ // `rule_plans` for the dataflow build).
+ let rule_meta: Vec<(usize, Vec)> = rule_plans
+ .iter()
+ .map(|rp| (rp.idx, rp.var_order.clone()))
+ .collect();
+
+ // The per-epoch input delta is already set-semantic: it comes from the
+ // versioned current-vs-fed map diff in `interpret::fused_bindings`, so
+ // each row appears at most once with weight ±1. The input integral
+ // therefore stays 0/1 per row without `.distinct()`. Adding that operator
+ // would perform redundant consolidation over the large relation integrals.
+ let inputs = worker.dataflow::(move |scope| {
+ // ONE shared input + base collection per distinct relation, shared by
+ // every atom occurrence (in every rule) that reads it.
+ let mut inputs: HashMap> = HashMap::new();
+ let mut rel_coll: HashMap = HashMap::new();
+ for &read in &reads_in {
+ let mut session: InputSession = InputSession::new();
+ let coll = session.to_collection(scope);
+ inputs.insert(read, session);
+ rel_coll.insert(read, coll);
+ }
+ // A collection-level join arranges both inputs at every call site.
+ // Share base-relation arrangements with the same key projection.
+ let mut arranged_right = HashMap::new();
+
+ for (rp, cap) in rule_plans.iter().zip(captures_in.iter()) {
+ // This rule's per-atom collection vector, from the SHARED relation
+ // collections (cloning a DD collection is just a handle copy).
+ let n_atoms = rp.atoms.len();
+ let atom_slots = &rp.atoms;
+ let proj = &rp.projection;
+ let step_col = &proj.step_col;
+ let slots0 = atom_slots[0].clone();
+ let sc0 = step_col[0].clone();
+ let mut cur = rel_coll[&rp.atom_reads[0]]
+ .clone()
+ .flat_map(move |r: Row| bind_atom(&r, &slots0, &sc0));
+
+ for i in 1..n_atoms {
+ let slots = atom_slots[i].clone();
+ let prev = &step_col[i - 1];
+ let next = &step_col[i];
+ let shared: Vec = atom_vars(&slots)
+ .into_iter()
+ .filter(|v| prev.contains_key(v))
+ .collect();
+ let shared_cols_left: Vec = shared.iter().map(|v| prev[v]).collect();
+ let shared_atom_cols: Vec = shared
+ .iter()
+ .map(|v| {
+ slots
+ .iter()
+ .position(|s| matches!(s, Slot::Var(x) if x == v))
+ .expect(
+ "DD join invariant: a shared variable must occur in the joined atom",
+ )
+ })
+ .collect();
+
+ let left_cols = shared_cols_left.clone();
+ let left = cur.map(move |b: Row| (pack_key(&b, &left_cols), b));
+ let arrangement_key = (rp.atom_reads[i], shared_atom_cols.clone());
+ let right = arranged_right
+ .entry(arrangement_key)
+ .or_insert_with(|| {
+ let right_cols = shared_atom_cols.clone();
+ rel_coll[&rp.atom_reads[i]]
+ .clone()
+ .map(move |r: Row| (pack_key(&r, &right_cols), r))
+ .arrange_by_key()
+ })
+ .clone();
+
+ let previous_layout = prev.clone();
+ let next_layout = next.clone();
+ cur = left.join_core(right, move |_key, binding, row| {
+ remap_merge_atom_into(binding, row, &slots, &previous_layout, &next_layout)
+ });
+ }
+
+ // Pack the variables needed by body primitives and head actions
+ // into the capture columns expected by `var_order()`.
+ let head_cols = proj.head_cols.clone();
+ let cur = cur.map(move |binding: Row| pack_key(&binding, &head_cols));
+
+ let cap = Rc::clone(cap);
+ // `step` accumulates captured deltas by binding row before
+ // interpreting their sign, so consolidating this stream in DD
+ // would duplicate the same per-key aggregation.
+ cur.inner
+ .inspect_batch(move |_t, batch| {
+ let mut buf = cap.borrow_mut();
+ for (row, _time, w) in batch.iter() {
+ buf.push((*row, *w));
+ }
+ })
+ .probe_with(&probe_in);
+ }
+
+ inputs
+ });
+
+ let rules: Vec = rule_meta
+ .into_iter()
+ .zip(captures)
+ .map(|((idx, var_order), captured)| FusedRule {
+ idx,
+ captured,
+ var_order,
+ })
+ .collect();
+
+ Ok(FusedDdJoin {
+ worker,
+ inputs,
+ probe,
+ reads,
+ rules,
+ epoch: 0,
+ })
+ }
+
+ /// The rule indices this fused worker serves (build order).
+ pub fn rule_indices(&self) -> Vec {
+ self.rules.iter().map(|r| r.idx).collect()
+ }
+
+ /// Distinct body relation read views across the ruleset, in first-use order.
+ pub fn read_keys(&self) -> &[ReadKey] {
+ &self.reads
+ }
+
+ /// Captured variable order for the fused rule at build position `pos`.
+ pub fn rule_var_order(&self, pos: usize) -> &[u32] {
+ &self.rules[pos].var_order
+ }
+
+ /// Feed one epoch of signed relation deltas into the SHARED inputs, advance
+ /// the timestamp, run the SINGLE worker to this epoch's fixpoint, and return
+ /// per-rule binding deltas. The outer `Vec` is in [`Self::rule_indices`]
+ /// order; each inner `Vec` is `(binding_row_as_var_order_vec, weight)`.
+ ///
+ /// CRUCIAL: the InputSessions are NEVER cleared — only the delta is pushed, so
+ /// the DD arrangements persist and the join is genuinely incremental.
+ pub fn step(&mut self, deltas: &DeltaMap) -> Result {
+ let mut pushed = false;
+ for (read, rows) in deltas {
+ let inp = self.inputs.get_mut(read).ok_or_else(|| {
+ anyhow!("DD invariant: received deltas for unplanned read view {read:?}")
+ })?;
+ for (row, w) in rows {
+ inp.update(pack_row(row)?, *w);
+ pushed = true;
+ }
+ }
+ let next_epoch = self
+ .epoch
+ .checked_add(1)
+ .ok_or_else(|| anyhow!("DD worker epoch overflow"))?;
+ if !pushed {
+ self.epoch = next_epoch;
+ return Ok(vec![Vec::new(); self.rules.len()]);
+ }
+
+ for rule in &self.rules {
+ rule.captured.borrow_mut().clear();
+ }
+ self.drive_to(next_epoch);
+ self.epoch = next_epoch;
+
+ let mut accs: Vec, isize>> =
+ (0..self.rules.len()).map(|_| HashMap::new()).collect();
+ for (rule, acc) in self.rules.iter().zip(accs.iter_mut()) {
+ for (row, weight) in rule.captured.borrow_mut().drain(..) {
+ let key = (0..rule.var_order.len()).map(|i| row[i]).collect();
+ *acc.entry(key).or_insert(0) += weight;
+ }
+ }
+ Ok(accs
+ .into_iter()
+ .map(|acc| acc.into_iter().filter(|(_, w)| *w != 0).collect())
+ .collect())
+ }
+
+ /// Step the SINGLE worker to `epoch`.
+ fn drive_to(&mut self, epoch: u32) {
+ for inp in self.inputs.values_mut() {
+ inp.advance_to(epoch);
+ inp.flush();
+ }
+ let probe = self.probe.clone();
+ self.worker.step_while(|| probe.less_than(&epoch));
+ }
+}
+
+/// Pack a slice of column values into a fixed-width row (0-padded).
+fn pack_row(vals: &[u32]) -> Result {
+ if vals.len() > W {
+ bail!(
+ "DD input row has {} columns, exceeding fixed row width {W}",
+ vals.len()
+ );
+ }
+ let mut a = Row::default();
+ for (i, v) in vals.iter().enumerate() {
+ a[i] = *v;
+ }
+ Ok(a)
+}
+
+/// Build a join key from selected columns (packed into the low slots).
+fn pack_key(r: &Row, cols: &[usize]) -> Row {
+ let mut a = Row::default();
+ for (i, &c) in cols.iter().enumerate() {
+ a[i] = r[c];
+ }
+ a
+}
+
+/// Distinct variables appearing in an atom (column order).
+fn atom_vars(slots: &[Slot]) -> Vec {
+ let mut out = Vec::new();
+ for s in slots {
+ if let Slot::Var(v) = s {
+ if !out.contains(v) {
+ out.push(*v);
+ }
+ }
+ }
+ out
+}
+
+/// Match the first atom's relation row against its slots, producing the initial
+/// binding row under the first-step layout (or no row if a constraint
+/// fails). Returns a `Vec` for `flat_map`.
+fn bind_atom(r: &Row, slots: &[Slot], layout: &HashMap) -> Vec {
+ let mut out = Row::default();
+ let mut local: HashMap = HashMap::new();
+ for (i, s) in slots.iter().enumerate() {
+ let val = r[i];
+ match s {
+ Slot::Const(c) => {
+ if *c != val {
+ return Vec::new();
+ }
+ }
+ Slot::Var(v) => {
+ if let Some(&prev) = local.get(v) {
+ if prev != val {
+ return Vec::new();
+ }
+ } else {
+ local.insert(*v, val);
+ out[layout[v]] = val;
+ }
+ }
+ }
+ }
+ vec![out]
+}
+
+/// Merge one atom into a binding while changing column layouts. `prev` is the
+/// left-row layout (step `i-1`), `cur` is the output layout (step `i`). Carried
+/// vars (live in both layouts but not produced here) are copied `prev[v]→cur[v]`;
+/// the atom's vars are validated (shared) or written (fresh) at `cur[v]`; every
+/// other output column is left zeroed. This is what reuses freed columns and so
+/// keeps the frontier within `W`. Returns no row on a constraint failure.
+fn remap_merge_atom_into(
+ b: &Row,
+ r: &Row,
+ slots: &[Slot],
+ prev: &HashMap,
+ cur: &HashMap,
+) -> Vec {
+ let mut out = Row::default();
+ // Carry over every still-live var (present in `cur`) that the left row
+ // already holds (present in `prev`). Atom-fresh vars are absent from `prev`,
+ // so they are NOT copied here — they are written from `r` below.
+ for (&v, &pc) in prev {
+ if let Some(&cc) = cur.get(&v) {
+ out[cc] = b[pc];
+ }
+ }
+ let mut local: HashMap = HashMap::new();
+ for (i, s) in slots.iter().enumerate() {
+ let val = r[i];
+ match s {
+ Slot::Const(c) => {
+ if *c != val {
+ return Vec::new();
+ }
+ }
+ Slot::Var(v) => {
+ if let Some(&prior) = local.get(v) {
+ if prior != val {
+ return Vec::new();
+ }
+ continue;
+ }
+ local.insert(*v, val);
+ // Every atom var is live at this step ⇒ present in `cur`.
+ let cc = cur[v];
+ if prev.contains_key(v) {
+ // Shared (already bound): the carried value must agree. (The
+ // join key already enforces this for the key columns; this
+ // also covers shared vars not in the key, if any.)
+ if out[cc] != val {
+ return Vec::new();
+ }
+ } else {
+ // Fresh var born at this atom: write it.
+ out[cc] = val;
+ }
+ }
+ }
+ }
+ vec![out]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use egglog_ast::{
+ core::{GenericAtom, GenericAtomTerm, GenericCoreAction, GenericCoreRule, Query},
+ span::Span,
+ };
+ use egglog_backend_trait::{
+ ColumnTy, FunctionId, ReadMode, RuleActionCall, RuleBodyCall, RuleSpec, RuleVar,
+ };
+ use egglog_numeric_id::NumericId;
+
+ fn live(func: FunctionId) -> ReadKey {
+ ReadKey {
+ func,
+ mode: ReadMode::Live,
+ }
+ }
+
+ fn variable(id: u32) -> GenericAtomTerm {
+ GenericAtomTerm::Var(
+ Span::Panic,
+ RuleVar {
+ id,
+ name: format!("v{id}").into_boxed_str(),
+ ty: ColumnTy::Id,
+ },
+ )
+ }
+
+ fn body_atom(
+ func: FunctionId,
+ vars: impl IntoIterator- ,
+ ) -> GenericAtom {
+ GenericAtom {
+ span: Span::Panic,
+ head: RuleBodyCall::Table {
+ id: func,
+ read: ReadMode::Live,
+ },
+ args: vars.into_iter().map(variable).collect(),
+ }
+ }
+
+ fn test_rule(
+ name: &str,
+ body: Vec>,
+ output: FunctionId,
+ vars: impl IntoIterator
- ,
+ ) -> RuleSpec {
+ let mut terms = vars.into_iter().map(variable).collect::>();
+ let value = terms.pop().expect("test output includes a value");
+ RuleSpec {
+ name: name.to_owned(),
+ seminaive: true,
+ no_decomp: false,
+ core: GenericCoreRule {
+ span: Span::Panic,
+ body: Query { atoms: body },
+ head: egglog_ast::core::GenericCoreActions::new(vec![GenericCoreAction::Set(
+ Span::Panic,
+ RuleActionCall::Table {
+ id: output,
+ name: "output".into(),
+ },
+ terms,
+ value,
+ )]),
+ },
+ }
+ }
+
+ /// Build a `JoinPlan` from `(func, vars)` atoms, preserving every variable
+ /// in the captured output through a synthetic head action.
+ fn plan_of(atoms: &[(FunctionId, &[u32])]) -> JoinPlan {
+ let body = atoms
+ .iter()
+ .map(|(func, vars)| body_atom(*func, vars.iter().copied()))
+ .collect();
+ let mut vars: Vec = atoms
+ .iter()
+ .flat_map(|(_, vars)| vars.iter().copied())
+ .collect();
+ vars.sort_unstable();
+ vars.dedup();
+ plan_join(&test_rule(
+ "test join",
+ body,
+ FunctionId::new(u32::MAX),
+ vars,
+ ))
+ .unwrap()
+ }
+
+ #[test]
+ fn pack_row_checks_the_fixed_width_boundary() {
+ let values = (0..W as u32).collect::>();
+ let packed = pack_row(&values).expect("a width-W row must fit");
+ assert_eq!(&packed.0[..], values.as_slice());
+
+ let error = pack_row(&[0; W + 1]).unwrap_err();
+ assert!(error.to_string().contains(&format!("{} columns", W + 1)));
+ assert!(error.to_string().contains(&format!("fixed row width {W}")));
+ }
+
+ #[test]
+ fn plan_reuses_columns_across_wide_variable_chain() {
+ let func = FunctionId::new(0);
+ let final_var = W as u32 + 1;
+ let body = (0..=W as u32)
+ .map(|var| body_atom(func, [var, var + 1]))
+ .collect();
+ let plan = plan_join(&test_rule("wide chain", body, func, [final_var])).unwrap();
+
+ assert_eq!(plan.var_order(), vec![final_var]);
+ assert!(plan
+ .projection
+ .step_col
+ .iter()
+ .all(|layout| layout.len() <= W));
+ }
+
+ #[test]
+ fn plan_rejects_live_variable_frontier_wider_than_row() {
+ let func = FunctionId::new(0);
+ let rule = test_rule(
+ "wide frontier",
+ vec![body_atom(func, 0..W as u32), body_atom(func, [0, W as u32])],
+ func,
+ 0..=W as u32,
+ );
+
+ let error = plan_join(&rule)
+ .err()
+ .expect("wide live frontier must fail");
+ assert!(error.contains("live body-variable frontier exceeds W"));
+ }
+
+ /// Feed one all-at-once delta of `rows` per func into a fresh
+ /// `FusedDdJoin`, returning the rule's sorted output bindings.
+ fn run_once(
+ atoms: &[(FunctionId, &[u32])],
+ rows: &HashMap,
+ ) -> Vec<(Vec, isize)> {
+ let plan = plan_of(atoms);
+ let mut j = FusedDdJoin::build(&[(0usize, plan)]).unwrap();
+ let deltas: DeltaMap = rows
+ .iter()
+ .map(|(&f, rows)| (live(f), rows.clone()))
+ .collect();
+ let mut out = j.step(&deltas).unwrap().remove(0);
+ out.sort();
+ out
+ }
+
+ #[test]
+ fn binary_join_handles_multi_atom_self_join() {
+ let f = FunctionId::new(0);
+ let g = FunctionId::new(1);
+ let atoms: &[(FunctionId, &[u32])] = &[(f, &[0, 1, 2]), (g, &[0, 3]), (g, &[1, 4])];
+ let mut rows = HashMap::new();
+ rows.insert(
+ f,
+ vec![
+ (vec![1, 2, 100], 1),
+ (vec![1, 3, 101], 1),
+ (vec![4, 2, 102], 1),
+ ],
+ );
+ rows.insert(
+ g,
+ vec![
+ (vec![1, 10], 1),
+ (vec![2, 20], 1),
+ (vec![3, 21], 1),
+ (vec![4, 40], 1),
+ ],
+ );
+
+ assert_eq!(
+ run_once(atoms, &rows),
+ vec![
+ (vec![1, 2, 100, 10, 20], 1),
+ (vec![1, 3, 101, 10, 21], 1),
+ (vec![4, 2, 102, 40, 20], 1),
+ ]
+ );
+ }
+}
diff --git a/egglog-experimental/dd/src/interpret.rs b/egglog-experimental/dd/src/interpret.rs
new file mode 100644
index 0000000..6a3f490
--- /dev/null
+++ b/egglog-experimental/dd/src/interpret.rs
@@ -0,0 +1,638 @@
+//! Host-side iteration driver for the Differential Dataflow backend.
+//!
+//! One `run_rules` call = **one bounded egglog iteration**. The body join runs
+//! on the in-process, build-once, epoch-driven raw differential-dataflow
+//! dataflow (`crate::dd_native`); this module owns the orchestration around it:
+//!
+//! 1. compute every rule's matches against the same pre-iteration relation
+//! state, following egglog's "match, then apply" model for one bounded hop;
+//! 2. drive the ruleset's persistent fused DD dataflow with the per-relation
+//! signed delta vs. what that dataflow was last fed, then re-run body primitives
+//! (`!=` guards, value-computing prims) host-side over the produced bindings;
+//! 3. for every surviving binding, execute the head ops in order — `set` /
+//! `remove` / `subsume` writes, RHS `lookup` (eq-sort constructor: create on
+//! miss), RHS primitive `call`, `union`, `panic`;
+//! 4. apply all collected writes/removes to the mirror, folding each merge
+//! function's `set`s into a single value per key by its merge mode.
+//!
+//! ## The engine split
+//!
+//! Relational table-atom joins are the only operations on the DD engine, and
+//! there is no host nested-loop fallback. Any rule the DD plan cannot lower (a
+//! binding row exceeding the fixed width cap [`crate::dd_native::W`], or any
+//! shape `plan_join` rejects) is reported as an error. Body primitives and head
+//! actions are applied host-side here.
+//!
+//! Primitives are invoked through `Database::with_execution_state`, so they see
+//! the same interned base `Value`s the frontend created and preserve bit-for-bit
+//! value parity with the reference backend.
+
+use anyhow::{anyhow, Result};
+use egglog_ast::core::{GenericAtom, GenericAtomTerm, GenericCoreAction, GenericCoreActions};
+use egglog_ast::generic_ast::Change;
+use egglog_backend_trait::{
+ FunctionId, ReadMode, RuleActionCall, RuleBodyCall, RuleSpec, RuleValue, RuleVar, Value,
+};
+use egglog_numeric_id::NumericId;
+use hashbrown::{HashMap, HashSet};
+
+use crate::compile::{MergeMode, ReadKey, Row};
+use crate::EGraph;
+
+/// Binding environment: variable id → bound `u32` value.
+pub(crate) type Env = HashMap;
+
+type DdDeltaRows = HashMap, isize)>>;
+
+/// Retractions batched per function: the key length plus the set of keys to
+/// remove, so one `retain` pass drops them all.
+type RemovesByFunc = HashMap>)>;
+
+/// Map the fused worker's rule order back to positions in the current caller.
+/// The worker cache key identifies a set of rule ids, so a cache hit does not
+/// imply that the caller supplied those ids in the original build order.
+fn fused_caller_positions(
+ atom_positions: &[usize],
+ atom_rule_idxs: &[usize],
+ fused_rule_idxs: &[usize],
+) -> Result> {
+ if atom_positions.len() != atom_rule_idxs.len() || atom_rule_idxs.len() != fused_rule_idxs.len()
+ {
+ return Err(anyhow!(
+ "fused rule mapping length mismatch: caller positions={}, caller ids={}, fused ids={}",
+ atom_positions.len(),
+ atom_rule_idxs.len(),
+ fused_rule_idxs.len()
+ ));
+ }
+
+ let mut caller_by_rule = HashMap::with_capacity(atom_rule_idxs.len());
+ for (&rule_idx, &position) in atom_rule_idxs.iter().zip(atom_positions) {
+ if caller_by_rule.insert(rule_idx, position).is_some() {
+ return Err(anyhow!(
+ "duplicate caller rule index {rule_idx} in fused ruleset"
+ ));
+ }
+ }
+
+ let mut seen = HashSet::with_capacity(fused_rule_idxs.len());
+ let mut positions = Vec::with_capacity(fused_rule_idxs.len());
+ for &rule_idx in fused_rule_idxs {
+ if !seen.insert(rule_idx) {
+ return Err(anyhow!(
+ "duplicate cached rule index {rule_idx} in fused ruleset"
+ ));
+ }
+ let position = caller_by_rule.get(&rule_idx).copied().ok_or_else(|| {
+ anyhow!("cached fused rule index {rule_idx} is absent from the caller ruleset")
+ })?;
+ positions.push(position);
+ }
+
+ Ok(positions)
+}
+
+/// A pending write to apply after all matches are computed.
+enum Write {
+ /// Insert/overwrite a full row.
+ Set(FunctionId, Row),
+ /// Retract by key (the slots address inputs for a function, whole row for a
+ /// relation).
+ Remove(FunctionId, Vec),
+ /// Soft-delete: move the row(s) whose leading columns match this prefix into
+ /// the `subsumed` side-set (hidden from ordinary matching, still present).
+ Subsume(FunctionId, Vec),
+}
+
+/// One bounded egglog iteration with the body join running on the in-process,
+/// build-once, epoch-driven raw differential-dataflow dataflow
+/// (`crate::dd_native`).
+///
+/// Compute the signed `+/-` delta of each distinct body-relation view vs. the
+/// rows last fed to the ruleset's persistent fused DD dataflow. Each nonempty
+/// removal or insertion phase steps the shared worker, feeding only deltas into
+/// never-cleared `InputSession`s. Positive per-rule binding deltas become envs;
+/// body primitives and head actions then run host-side. Writes and FD merges are
+/// applied afterward so results are bit-exact.
+pub fn run_iteration(eg: &mut EGraph, rules: &[(usize, RuleSpec)]) -> Result {
+ // Every rule — including the term encoder's canonicalization rules — lowers
+ // as an ordinary rule and joins on the fused DD worker; there is no special
+ // casing for any rule kind.
+
+ // Snapshot the fresh-id counter: any hash-cons (`lookup_or_create`) this
+ // call advances it, the O(1) signal that a new term row was created.
+ let next_id_at_start = eg.next_id;
+
+ let mut writes: Vec = Vec::new();
+ // Iteration-scoped `key -> output` index for `lookup_or_create` (eq-sort
+ // constructor hash-cons). Built lazily per function so repeated lookups in
+ // one iteration are O(1) instead of rescanning the growing mirror each time.
+ let mut lookup_index: HashMap, u32>> = HashMap::new();
+
+ // Compute every rule's binding envs FIRST (so the whole atom-bearing ruleset
+ // runs on one fused DD worker via `fused_bindings`), THEN
+ // apply head actions in the original rule firing order. Atom-less rules
+ // (`(rule () …)`) have no input relation to drive the DD dataflow, so they
+ // stay host-side (fire once); they are computed inline below.
+ let envs_by_rule = fused_bindings(eg, rules)?;
+
+ for ((_, rule), envs) in rules.iter().zip(envs_by_rule.into_iter()) {
+ for mut env in envs {
+ apply_head(
+ eg,
+ &rule.core.head,
+ &mut env,
+ &mut writes,
+ &mut lookup_index,
+ )?;
+ }
+ }
+
+ // Apply collected writes to the mirror.
+ //
+ // Removes are BATCHED per function: applying each `Write::Remove` with its
+ // own `set.retain` scan is O(|removes| · |state|) — quadratic. We collect all
+ // retraction keys per function into a hash set, then do a SINGLE `retain`
+ // pass per touched function: O(|state|) total. Removes are applied FIRST
+ // (batched), then Sets — preserving the term encoder's `(@uf)` "delete old
+ // leader, set new leader" delete-then-set ordering.
+ //
+ // `changed` is computed INCREMENTALLY as writes land (O(delta)), not via a
+ // full before/after content compare. A hash-cons in `lookup_or_create`
+ // always allocates a fresh id, so any term created this call advances
+ // `next_id` — that alone is a real mirror change.
+ let mut changed = eg.next_id != next_id_at_start;
+ let mut removes_by_func: RemovesByFunc = HashMap::new();
+ let mut sets: Vec<(FunctionId, Row)> = Vec::new();
+ let mut subsumes: Vec<(FunctionId, Vec)> = Vec::new();
+ for w in writes {
+ match w {
+ Write::Set(f, row) => sets.push((f, row)),
+ Write::Remove(f, key) => {
+ let entry = removes_by_func
+ .entry(f)
+ .or_insert_with(|| (key.len(), HashSet::new()));
+ entry.1.insert(key.into_boxed_slice());
+ }
+ Write::Subsume(f, prefix) => subsumes.push((f, prefix)),
+ }
+ }
+ for (f, (keylen, keys)) in removes_by_func {
+ // A `delete`/rebuild retraction clears the row from BOTH the live mirror
+ // and the subsumed side-set (a rebuilt-away subsumed row must not linger).
+ changed |= eg.remove_matching_keys(f, keylen, &keys);
+ }
+ // Apply sets. A plain relation (whole-row key) just inserts. A merge function
+ // (Old/New/Min) folds each new value against the CURRENT value for its key by
+ // the merge mode; the new values are folded in EMISSION order so `New`/`Old`
+ // are insertion-order-correct — the pre-set mirror value is the "old" one and
+ // the value set this call is the "new" one. (The mirror is an unordered
+ // `HashSet`, so a fold that sorted the candidate rows would pick the wrong
+ // winner for `New`/`Old`: e.g. a `New` merge of `@UF_Mf` where a leader
+ // changes from `3` to `1` must keep `1`, not the sort-larger old value `3`.)
+ let mut merge_by_func: HashMap> = HashMap::new();
+ for (f, row) in sets {
+ let merge = eg.merge_mode(f);
+ if merge == MergeMode::Relation {
+ changed |= eg.insert_live_row(f, row);
+ } else {
+ merge_by_func.entry(f).or_default().push(row);
+ }
+ }
+ for (f, rows) in merge_by_func {
+ changed |= eg.apply_merge_sets(f, &rows)?;
+ }
+ // Subsumes last: a row `set` this iteration can then be subsumed, and the
+ // move reads the just-updated live mirror.
+ for (f, prefix) in subsumes {
+ changed |= eg.subsume_rows(f, &prefix);
+ }
+
+ Ok(changed)
+}
+
+/// Compute every rule's binding envs in ONE fused pass: the whole atom-bearing
+/// ruleset's body joins run on a SINGLE shared timely worker
+/// ([`dd_native::FusedDdJoin`]) clocked once this iteration, then each rule's
+/// host-side body primitives are re-run over its own bindings. Atom-less rules
+/// (`(rule () …)`) have no input relation to drive the DD dataflow, so they are
+/// fired once host-side. Returns a `Vec>` parallel to `rules` (same
+/// order), ready for `apply_head`.
+fn fused_bindings(eg: &mut EGraph, rules: &[(usize, RuleSpec)]) -> Result>> {
+ use crate::dd_native;
+
+ let mut out: Vec> = vec![Vec::new(); rules.len()];
+
+ // Partition: atom-bearing rules drive the fused DD worker; atom-less rules
+ // fire once host-side. Record each atom-bearing rule's POSITION in `rules` so
+ // we can scatter the fused output back into `out` in the caller's order.
+ let mut atom_positions: Vec = Vec::new();
+ let mut atom_rule_idxs: Vec = Vec::new();
+ for (pos, (idx, rule)) in rules.iter().enumerate() {
+ let has_atoms = rule
+ .core
+ .body
+ .atoms
+ .iter()
+ .any(|atom| matches!(atom.head, RuleBodyCall::Table { .. }));
+ if has_atoms {
+ atom_positions.push(pos);
+ atom_rule_idxs.push(*idx);
+ } else {
+ // Atom-less rule: fire once (presence in `seen` = already fired).
+ if eg.seen.contains_key(idx) {
+ continue;
+ }
+ eg.seen.insert(*idx, ());
+ let mut envs: Vec = vec![Env::new()];
+ for atom in &rule.core.body.atoms {
+ if matches!(atom.head, RuleBodyCall::Primitive { .. }) {
+ envs = step_prim(eg, atom, envs)?;
+ if envs.is_empty() {
+ break;
+ }
+ }
+ }
+ out[pos] = envs;
+ }
+ }
+
+ if atom_positions.is_empty() {
+ return Ok(out);
+ }
+
+ // The fused join is keyed by the SORTED atom-bearing rule-index list (the
+ // ruleset identity). Build it ONCE (lazily) per distinct ruleset, planning
+ // each rule. Any shape `plan_join` rejects is returned as an error (there is
+ // no host fallback; the DD dataflow is the only join path).
+ let mut key: Vec = atom_rule_idxs.clone();
+ key.sort_unstable();
+
+ if !eg.dd_fused.contains_key(&key) {
+ // Plan in the SAME order as `atom_positions` so the fused build order
+ // matches our scatter order (the fused join preserves plan order).
+ let mut plans: Vec<(usize, dd_native::JoinPlan)> = Vec::with_capacity(atom_positions.len());
+ for (&pos, &idx) in atom_positions.iter().zip(atom_rule_idxs.iter()) {
+ let rule = &rules[pos].1;
+ let plan = dd_native::plan_join(rule).map_err(|reason| {
+ anyhow!(
+ "Differential Dataflow join cannot lower rule {:?}: {reason} \
+ (no host fallback; the DD dataflow is the only join path)",
+ rule.name
+ )
+ })?;
+ plans.push((idx, plan));
+ }
+ let fused = dd_native::FusedDdJoin::build(&plans)?;
+ eg.dd_fused.insert(key.clone(), fused);
+ }
+
+ // Capture the fused worker's build order and map each output slot back to
+ // the current caller by stable rule id. The sorted cache key only proves set
+ // equality; it does not prove that this call uses the original order.
+ let (fused_rule_idxs, var_orders, all_reads): (Vec, Vec>, Vec) = {
+ let fused = eg
+ .dd_fused
+ .get(&key)
+ .expect("DD cache invariant: fused join was inserted for this ruleset key");
+ let rule_idxs = fused.rule_indices();
+ let var_orders = (0..rule_idxs.len())
+ .map(|p| fused.rule_var_order(p).to_vec())
+ .collect();
+ (rule_idxs, var_orders, fused.read_keys().to_vec())
+ };
+ let fused_positions =
+ fused_caller_positions(&atom_positions, &atom_rule_idxs, &fused_rule_idxs)?;
+
+ // Distinct relation read views across the whole ruleset. We diff versioned
+ // current snapshots rather than plain row sets: if another ruleset removes and
+ // reinserts the same row between two invocations, the row is present in both
+ // end states but has a fresh version. Feeding `-row` then `+row` as separate
+ // DD steps preserves the reference backend's hidden-timestamp behavior
+ // without replaying transient rows that are no longer visible.
+ let mut removals_batch: DdDeltaRows = HashMap::new();
+ let mut insertions_batch: DdDeltaRows = HashMap::new();
+ {
+ let fed = eg.dd_fused_fed_versions.entry(key.clone()).or_default();
+ for &read in &all_reads {
+ let cur = match read.mode {
+ ReadMode::Live => eg.live_versions.get(&read.func),
+ ReadMode::Subsumed => eg.subsumed_versions.get(&read.func),
+ ReadMode::All => eg.all_versions.get(&read.func),
+ };
+ let cur_empty: HashMap
= HashMap::new();
+ let cur = cur.unwrap_or(&cur_empty);
+ let prev = fed.entry(read).or_default();
+ if prev == cur {
+ continue;
+ }
+
+ let mut removals = Vec::new();
+ let mut insertions = Vec::new();
+ for row in prev.keys() {
+ if !cur.contains_key(row) {
+ removals.push((row.to_vec(), -1));
+ }
+ }
+ for (row, version) in cur {
+ match prev.get(row) {
+ None => insertions.push((row.to_vec(), 1)),
+ Some(prev_version) if prev_version != version => {
+ removals.push((row.to_vec(), -1));
+ insertions.push((row.to_vec(), 1));
+ }
+ Some(_) => {}
+ }
+ }
+ if !removals.is_empty() {
+ removals_batch.insert(read, removals);
+ }
+ if !insertions.is_empty() {
+ insertions_batch.insert(read, insertions);
+ }
+ *prev = cur.clone();
+ }
+ }
+ let mut delta_batches: Vec = Vec::new();
+ if !removals_batch.is_empty() {
+ delta_batches.push(removals_batch);
+ }
+ if !insertions_batch.is_empty() {
+ delta_batches.push(insertions_batch);
+ }
+
+ // Step the shared worker once per nonempty signed-delta phase. A version
+ // change may require a removal phase followed by an insertion phase.
+ let mut per_rule_bindings = vec![Vec::new(); fused_positions.len()];
+ {
+ let fused = eg
+ .dd_fused
+ .get_mut(&key)
+ .expect("DD cache invariant: fused join was inserted for this ruleset key");
+ for delta in &delta_batches {
+ let stepped = fused.step(delta)?;
+ for (acc, rows) in per_rule_bindings.iter_mut().zip(stepped) {
+ acc.extend(rows);
+ }
+ }
+ }
+
+ // Turn each rule's positive binding deltas into envs; re-run its body prims
+ // host-side. Negative weights are integral bookkeeping (a body row retracted)
+ // — egglog heads are monotone-fire, so we do NOT re-fire on disappearance.
+ for (fpos, bindings) in per_rule_bindings.into_iter().enumerate() {
+ let caller_pos = fused_positions[fpos];
+ let rule = &rules[caller_pos].1;
+ let var_order = &var_orders[fpos];
+ let mut envs: Vec = Vec::new();
+ for (bind, w) in &bindings {
+ if *w <= 0 {
+ continue;
+ }
+ let mut env: Env = Env::new();
+ for (i, &v) in var_order.iter().enumerate() {
+ env.insert(v, bind[i]);
+ }
+ let mut es: Vec = vec![env];
+ for atom in &rule.core.body.atoms {
+ if matches!(atom.head, RuleBodyCall::Primitive { .. }) {
+ es = step_prim(eg, atom, es)?;
+ }
+ }
+ envs.extend(es);
+ }
+ out[caller_pos] = envs;
+ }
+
+ Ok(out)
+}
+
+/// Evaluate a primitive body op over each binding env, returning the new list of
+/// envs. A value-computing prim binds (or checks) its return var; a guard prim
+/// (`!=`) that fails prunes the env. Table atoms are NOT handled here — they run
+/// on the DD dataflow; this is only the host-side primitive phase.
+pub(crate) fn step_prim(
+ eg: &mut EGraph,
+ atom: &GenericAtom,
+ envs: Vec,
+) -> Result> {
+ let RuleBodyCall::Primitive { id, .. } = atom.head else {
+ unreachable!("step_prim called on a non-primitive body op");
+ };
+ let (ret, args) = atom
+ .args
+ .split_last()
+ .ok_or_else(|| anyhow!("body primitive has no return term"))?;
+ let mut out = Vec::new();
+ for env in envs {
+ let resolved: Option> = args
+ .iter()
+ .map(|term| term_value(term, &env).map(Value::new))
+ .collect();
+ let Some(argv) = resolved else { continue };
+ let result = eg.eval_prim_internal(id, &argv)?;
+ let Some(result) = result else {
+ // Primitive failed (e.g. `!=` of equal args) — prune.
+ continue;
+ };
+ match ret {
+ GenericAtomTerm::Var(_, variable) => {
+ let mut next = env.clone();
+ match next.get(&variable.id) {
+ Some(&existing) if existing != result.rep() => continue,
+ _ => {
+ next.insert(variable.id, result.rep());
+ }
+ }
+ out.push(next);
+ }
+ GenericAtomTerm::Literal(_, constant) => {
+ if constant.value.rep() == result.rep() {
+ out.push(env);
+ }
+ }
+ GenericAtomTerm::Global(..) => {
+ unreachable!("DD rule validation must reject global primitive return terms")
+ }
+ }
+ }
+ Ok(out)
+}
+
+/// Execute the head ops for one binding, accumulating writes.
+fn apply_head(
+ eg: &mut EGraph,
+ head: &GenericCoreActions,
+ env: &mut Env,
+ writes: &mut Vec,
+ lookup_index: &mut HashMap, u32>>,
+) -> Result<()> {
+ for action in &head.0 {
+ match action {
+ GenericCoreAction::Let(_, variable, call, arguments) => {
+ let arguments = resolve_terms(arguments, env)?;
+ let result = match call {
+ RuleActionCall::Table { id, .. } => {
+ let key = arguments
+ .iter()
+ .copied()
+ .map(Value::new)
+ .collect::>();
+ if eg.info(*id).lookup_mints {
+ Some(lookup_or_create(eg, *id, &key, lookup_index))
+ } else {
+ Some(lookup_existing(eg, *id, &key, lookup_index).ok_or_else(|| {
+ anyhow!(
+ "lookup on `{}` failed in rule action",
+ eg.relation_name(*id)
+ )
+ })?)
+ }
+ }
+ RuleActionCall::Primitive { id, .. } => {
+ let arguments = arguments
+ .iter()
+ .copied()
+ .map(Value::new)
+ .collect::>();
+ eg.eval_prim_internal(*id, &arguments)?
+ }
+ };
+ if let Some(result) = result {
+ env.insert(variable.id, result.rep());
+ }
+ }
+ GenericCoreAction::LetAtomTerm(_, variable, term) => {
+ env.insert(variable.id, resolve_term(term, env)?);
+ }
+ GenericCoreAction::Set(_, call, arguments, value) => {
+ let RuleActionCall::Table { id, .. } = call else {
+ return Err(anyhow!("DD backend cannot set a primitive"));
+ };
+ let mut row = resolve_terms(arguments, env)?;
+ row.push(resolve_term(value, env)?);
+ writes.push(Write::Set(*id, row.into_boxed_slice()));
+ }
+ GenericCoreAction::Change(_, change, call, arguments) => {
+ let RuleActionCall::Table { id, .. } = call else {
+ return Err(anyhow!("DD backend cannot delete or subsume a primitive"));
+ };
+ let values = resolve_terms(arguments, env)?;
+ match change {
+ Change::Delete => writes.push(Write::Remove(*id, values)),
+ Change::Subsume => writes.push(Write::Subsume(*id, values)),
+ }
+ }
+ GenericCoreAction::Union(..) => {
+ // Term encoding lowers unions to `(set (@uf ...))` writes. Do not
+ // silently discard a native union if this backend is driven
+ // outside that required frontend mode.
+ return Err(anyhow!(
+ "DD backend received a native union; term encoding must lower unions to @uf writes"
+ ));
+ }
+ GenericCoreAction::Panic(_, msg) => {
+ return Err(anyhow!("{msg}"));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn term_value(term: &GenericAtomTerm, env: &Env) -> Option {
+ match term {
+ GenericAtomTerm::Var(_, variable) => env.get(&variable.id).copied(),
+ GenericAtomTerm::Literal(_, value) => Some(value.value.rep()),
+ GenericAtomTerm::Global(..) => {
+ unreachable!("DD rule validation must reject residual global terms")
+ }
+ }
+}
+
+fn resolve_term(term: &GenericAtomTerm, env: &Env) -> Result {
+ term_value(term, env).ok_or_else(|| anyhow!("unbound term {term:?} in rule head"))
+}
+
+fn resolve_terms(terms: &[GenericAtomTerm], env: &Env) -> Result> {
+ terms.iter().map(|term| resolve_term(term, env)).collect()
+}
+
+/// Look up the output of `func` for input `key`. If absent, create the row with
+/// a fresh id (eq-sort constructor semantics — mirrors `add_term`). The created
+/// row is written directly into the mirror so subsequent lookups in the same
+/// iteration see it (hash-cons).
+pub(crate) fn lookup_or_create(
+ eg: &mut EGraph,
+ func: FunctionId,
+ key: &[Value],
+ index: &mut HashMap, u32>>,
+) -> Value {
+ if let Some(value) = lookup_existing(eg, func, key, index) {
+ return value;
+ }
+ let k: Box<[u32]> = key.iter().map(|v| v.rep()).collect();
+ let id = eg.fresh_id_internal();
+ index.entry(func).or_default().insert(k, id);
+ let mut full: Vec = key.iter().map(|v| v.rep()).collect();
+ full.push(id);
+ let row: Row = full.into_boxed_slice();
+ eg.insert_live_row(func, row);
+ Value::new(id)
+}
+
+/// Look up the current output of `func` for input `key` without creating a row.
+pub(crate) fn lookup_existing(
+ eg: &EGraph,
+ func: FunctionId,
+ key: &[Value],
+ index: &mut HashMap, u32>>,
+) -> Option {
+ let info = eg.info(func);
+ let inputs_len = info.arity.saturating_sub(1);
+ // Lazily build the key->output index for this function from live ∪ subsumed
+ // rows so repeated lookups within one iteration are O(1) instead of O(state)
+ // scans. A subsumed constructor row is still the current table row in the
+ // reference backend; looking it up must not mint a fresh visible row.
+ let idx = index.entry(func).or_insert_with(|| {
+ let live_len = eg.mirror.get(&func).map_or(0, |rows| rows.len());
+ let subsumed_len = eg.subsumed.get(&func).map_or(0, |rows| rows.len());
+ let mut m: HashMap, u32> = HashMap::with_capacity(live_len + subsumed_len);
+ if let Some(set) = eg.mirror.get(&func) {
+ for row in set.iter() {
+ let k: Box<[u32]> = (0..inputs_len).map(|i| row[i]).collect();
+ m.insert(k, row[inputs_len]);
+ }
+ }
+ if let Some(set) = eg.subsumed.get(&func) {
+ for row in set.iter() {
+ let k: Box<[u32]> = (0..inputs_len).map(|i| row[i]).collect();
+ m.entry(k).or_insert(row[inputs_len]);
+ }
+ }
+ m
+ });
+ let k: Box<[u32]> = key.iter().map(|v| v.rep()).collect();
+ idx.get(&k).copied().map(Value::new)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::fused_caller_positions;
+
+ #[test]
+ fn fused_rule_mapping_uses_rule_ids_instead_of_caller_order() {
+ assert_eq!(
+ fused_caller_positions(&[4, 9], &[10, 20], &[20, 10]).unwrap(),
+ [9, 4]
+ );
+ }
+
+ #[test]
+ fn fused_rule_mapping_rejects_inconsistent_cache_entries() {
+ let missing = fused_caller_positions(&[4, 9], &[10, 20], &[10, 30]).unwrap_err();
+ assert!(missing.to_string().contains("absent from the caller"));
+
+ let duplicate = fused_caller_positions(&[4, 9], &[10, 20], &[10, 10]).unwrap_err();
+ assert!(duplicate.to_string().contains("duplicate cached rule"));
+ }
+}
diff --git a/egglog-experimental/dd/src/lib.rs b/egglog-experimental/dd/src/lib.rs
new file mode 100644
index 0000000..b48f06d
--- /dev/null
+++ b/egglog-experimental/dd/src/lib.rs
@@ -0,0 +1,1965 @@
+//! # egglog-experimental-dd
+//!
+//! A differential-dataflow-backed implementation of egglog's
+//! [`egglog_backend_trait::Backend`] interface.
+//!
+//! One `run_rules` call is one bounded egglog iteration. Each rule's body
+//! table-atom join runs on an in-process differential-dataflow dataflow
+//! (`dd_native`); body primitives and head actions are applied
+//! host-side (`interpret`) into a Rust-side materialized mirror of every
+//! relation. `for_each` / `lookup_id` / `table_size` read that mirror.
+//!
+//! ## Why this is a DD backend
+//!
+//! This crate constructs Timely and Differential Dataflow operators directly;
+//! it does not compile through a higher-level dataflow language or runtime. That
+//! keeps this prototype focused on the backend SPI and the costs of persistent
+//! incremental joins. Evaluating a higher-level compiler/runtime, including its
+//! tuple generation, planning, and stratification choices, would be a separate
+//! backend experiment rather than an interchangeable implementation detail here.
+
+use std::{
+ any::{Any, TypeId},
+ sync::{Arc, Mutex},
+};
+
+use anyhow::{anyhow, bail, Result};
+use egglog_ast::core::{GenericAtomTerm, GenericCoreAction};
+use egglog_backend_trait::{
+ Backend, BaseValues, ColumnTy, ContainerMergeFn, ContainerValues, CounterId, DefaultVal,
+ ExecutionState, ExternalFunction, ExternalFunctionId, FunctionConfig, FunctionId,
+ IterationReport, MergeFn, ReportLevel, RuleActionCall, RuleBodyCall, RuleId, RuleSetRun,
+ RuleSpec, RuleValue, RuleVar, ScanEntry, Value,
+};
+use egglog_core_relations::Database;
+use egglog_numeric_id::NumericId;
+use hashbrown::{HashMap, HashSet};
+
+mod compile;
+mod dd_native;
+mod interpret;
+
+use compile::{MergeMode, MergeTree, ReadKey, Row};
+
+type LocatedValue = (u32, RowLocation);
+type RowReplacement = (Box<[u32]>, LocatedValue);
+
+mod dd_workers {
+ use hashbrown::HashMap;
+
+ use crate::dd_native;
+
+ /// Owns the single-threaded Timely workers behind an exclusive-access
+ /// boundary. The worker map is private to this module, and every accessor
+ /// requires `&mut self`, so immutable backend operations cannot touch
+ /// Timely's `Rc`/`RefCell` state.
+ #[derive(Default)]
+ pub(super) struct DdWorkers {
+ inner: HashMap, dd_native::FusedDdJoin>,
+ }
+
+ impl DdWorkers {
+ pub(super) fn contains_key(&mut self, key: &[usize]) -> bool {
+ self.inner.contains_key(key)
+ }
+
+ pub(super) fn insert(&mut self, key: Vec, value: dd_native::FusedDdJoin) {
+ self.inner.insert(key, value);
+ }
+
+ pub(super) fn get(&mut self, key: &[usize]) -> Option<&dd_native::FusedDdJoin> {
+ self.inner.get(key)
+ }
+
+ pub(super) fn get_mut(&mut self, key: &[usize]) -> Option<&mut dd_native::FusedDdJoin> {
+ self.inner.get_mut(key)
+ }
+
+ pub(super) fn retain(&mut self, mut keep: impl FnMut(&Vec) -> bool) {
+ self.inner.retain(|key, _| keep(key));
+ }
+
+ #[cfg(test)]
+ pub(super) fn is_empty(&mut self) -> bool {
+ self.inner.is_empty()
+ }
+ }
+
+ // SAFETY: `FusedDdJoin` does not expose or clone its Timely handles outside
+ // this private owner. Moving `DdWorkers` moves every related handle together,
+ // and the only worker accessors require `&mut self`. In particular, `Sync`
+ // cannot be used to reach Timely state through a shared reference.
+ unsafe impl Send for DdWorkers {}
+ unsafe impl Sync for DdWorkers {}
+}
+
+use dd_workers::DdWorkers;
+
+// ---------------------------------------------------------------------------
+// Relation metadata
+// ---------------------------------------------------------------------------
+
+/// What we remember about each registered relation/function.
+#[derive(Clone)]
+pub(crate) struct RelationInfo {
+ name: String,
+ /// Number of columns (including the output column for functions).
+ pub(crate) arity: usize,
+ /// Whether RHS lookups may create a fresh output row on miss.
+ pub(crate) lookup_mints: bool,
+ /// How functional-dependency conflicts are resolved.
+ pub(crate) merge: MergeMode,
+ /// The evaluatable merge tree, when `merge` is [`MergeMode::Computed`].
+ pub(crate) merge_tree: Option,
+}
+
+// ---------------------------------------------------------------------------
+// EGraph
+// ---------------------------------------------------------------------------
+
+/// The experimental differential-dataflow-backed egraph.
+pub struct EGraph {
+ relations: Vec,
+ /// Rule slots; `None` = freed.
+ pub(crate) rules: Vec