C内核按库功能拆解,编译结果缓存区构建,编译过程与已有缓存结果对照功能实现
This commit is contained in:
1 parent
aa4951b14e
commit
151e6e4b97
30 files changed
+4856
-604
No files matched your search
@@ -0,0 +1,382 @@
|
||||
"""Prepare or serially measure frozen/current native builds; never run a model.
|
||||
|
||||
Example (fresh output directory; omit --run for preparation only)::
|
||||
|
||||
.venv/bin/python tests/manual/benchmark_native_build_cache.py \
|
||||
--baseline-root test/incremental-build-20260912/baseline-source \
|
||||
--output-dir test/incremental-build-20260912/benchmark \
|
||||
--warmups 1 --repeats 3 --run
|
||||
|
||||
Each version/round gets an empty application cache, followed by A/full hit,
|
||||
A-prime/changed pipe length, A/full hit, four branches/first build, A/full hit.
|
||||
Application-cache cold does not mean OS page-cache cold. Versions run in separate
|
||||
Python processes, with alternating version order by round. Preparation, imports,
|
||||
hashing and recording are outside build timing. NativeBuild.close() is measured
|
||||
separately and as part of an adjacent build-and-release wall interval. Compilation
|
||||
retains each production builder's concurrency and flags. No solver, frontend,
|
||||
strace or previous diagnostic timings are invoked/combined.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import csv
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = Path(__file__).resolve()
|
||||
SCENARIOS = (
|
||||
("cold", "a", False), ("same_model", "a", True),
|
||||
("parameter_change", "a_prime", False), ("return_to_a", "a", True),
|
||||
("four_branches", "four", False), ("return_from_four", "a", True),
|
||||
)
|
||||
METRICS = ("buildSeconds", "buildCallWallSeconds", "releaseWallSeconds",
|
||||
"buildAndReleaseWallSeconds", "executableBytes", "cacheBytes",
|
||||
"preprocessSeconds", "compileSeconds", "compileWallSeconds",
|
||||
"linkSeconds", "unitCount", "objectCacheHits", "objectCompilations")
|
||||
ENVIRONMENT = ("SIMULATION_NATIVE_CC", "SUNDIALS_ROOT",
|
||||
"SIMULATION_NATIVE_MODEL_CACHE_MB", "SIMULATION_NATIVE_OBJECT_CACHE_MB")
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
value = sha256()
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
value.update(block)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def json_digest(value) -> str:
|
||||
return sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value) -> None:
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def source_files(root: Path) -> dict:
|
||||
paths = list((root / "app").rglob("*.py"))
|
||||
paths += [p for directory in ("native", "schemas") for p in (root / directory).rglob("*") if p.is_file()]
|
||||
return {p.relative_to(root).as_posix(): digest(p) for p in sorted(paths)
|
||||
if "__pycache__" not in p.parts and not p.relative_to(root).as_posix().startswith("app/data/")}
|
||||
|
||||
|
||||
def source_record(root: Path) -> dict:
|
||||
if not (root / "app/simulation/native_codegen/build.py").is_file() or not (root / "native").is_dir():
|
||||
raise ValueError(f"Incomplete source root: {root}")
|
||||
frozen = root / "source-manifest.json"
|
||||
if frozen.exists():
|
||||
manifest = json.loads(frozen.read_text())
|
||||
for name, expected in manifest["trackedFiles"].items():
|
||||
if digest(root / name) != expected:
|
||||
raise ValueError(f"Frozen source integrity failure: {root / name}")
|
||||
revision = {"gitHead": manifest["head"], "frozenManifestSha256": digest(frozen), "worktreeStatus": None}
|
||||
else:
|
||||
revision = {"gitHead": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip(),
|
||||
"worktreeStatus": subprocess.check_output(["git", "status", "--porcelain", "--", "app", "native", "schemas"], cwd=root, text=True)}
|
||||
files = source_files(root)
|
||||
return {"root": str(root), **revision, "sourceFiles": files, "sourceFilesSha256": json_digest(files)}
|
||||
|
||||
|
||||
def assert_source(record: dict) -> None:
|
||||
if source_files(Path(record["root"])) != record["sourceFiles"]:
|
||||
raise RuntimeError(f"Source changed after preparation: {record['root']}")
|
||||
|
||||
|
||||
def make_inputs(args, output: Path) -> tuple[dict, dict]:
|
||||
directory = output / "inputs"
|
||||
directory.mkdir()
|
||||
paths = {"a": directory / "model-a.json", "a_prime": directory / "model-a-prime.json", "four": directory / "model-four.json"}
|
||||
shutil.copyfile(args.input, paths["a"])
|
||||
shutil.copyfile(args.four_input, paths["four"])
|
||||
original = json.loads(paths["a"].read_text(encoding="utf-8"))
|
||||
variant = deepcopy(original)
|
||||
matches = [(i, node) for i, node in enumerate(variant["nodes"]) if node["id"] == args.variant_node]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected exactly one variant node: {args.variant_node}")
|
||||
index, node = matches[0]
|
||||
data = node["data"]
|
||||
if data["modelType"] != "amesim_pnl0001":
|
||||
raise ValueError("The controlled length variant requires an amesim_pnl0001 pipe")
|
||||
old = data["parameters"]["le"]
|
||||
if type(old) not in (int, float) or not math.isfinite(old) or old <= 0:
|
||||
raise ValueError("Pipe le must be a positive numeric SI value")
|
||||
new = old * 1.001
|
||||
if not math.isfinite(new) or new == old:
|
||||
raise ValueError("Pipe length variant did not produce a finite changed value")
|
||||
data["parameters"]["le"] = new
|
||||
write_json(paths["a_prime"], variant)
|
||||
data["parameters"]["le"] = old
|
||||
if variant != original:
|
||||
raise RuntimeError("Variant changed fields beyond the selected pipe length")
|
||||
inputs = {key: {"path": str(path), "sha256": digest(path)} for key, path in paths.items()}
|
||||
change = {"nodeId": node["id"], "modelType": data["modelType"], "jsonPointer": f"/nodes/{index}/data/parameters/le",
|
||||
"before": old, "after": new, "storedUnit": "m", "factor": 1.001,
|
||||
"note": "Stored numeric values are SI; editor parameterUnits do not rescale them."}
|
||||
return inputs, change
|
||||
|
||||
|
||||
def generate(config: dict, version: str, *, save: bool):
|
||||
source = config["versions"][version]
|
||||
assert_source(source)
|
||||
root = Path(source["root"])
|
||||
# Every worker starts a fresh interpreter; neither version imports the other.
|
||||
sys.path.insert(0, str(root))
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
from app.simulation.native_codegen import build as builder
|
||||
for name, module in tuple(sys.modules.items()):
|
||||
location = getattr(module, "__file__", None)
|
||||
if (name == "app" or name.startswith("app.")) and location and not Path(location).resolve().is_relative_to(root):
|
||||
raise RuntimeError(f"Mixed source imports: {name}: {location}")
|
||||
if builder.NATIVE.resolve() != root / "native":
|
||||
raise RuntimeError("Builder native source root differs from the selected version")
|
||||
generated = Path(config["outputDir"]) / version / "generated"
|
||||
if save:
|
||||
generated.mkdir(parents=True)
|
||||
programs, records = {}, {}
|
||||
for key, item in config["inputs"].items():
|
||||
path = Path(item["path"])
|
||||
if digest(path) != item["sha256"]:
|
||||
raise RuntimeError(f"Input changed after preparation: {path}")
|
||||
started = time.perf_counter()
|
||||
xml, document = load_input(path)
|
||||
program = compile_native_program(compile_system_xml_network(document))
|
||||
preparation = time.perf_counter() - started
|
||||
contract = program.manifest()
|
||||
record = {"inputSha256": item["sha256"], "xmlSha256": sha256(xml).hexdigest(),
|
||||
"sourceSha256": sha256(program.source.encode()).hexdigest(), "headerSha256": sha256(program.header.encode()).hexdigest(),
|
||||
"contractSha256": json_digest(contract), "stateCount": len(program.state_keys),
|
||||
"variableCount": len(program.variables)}
|
||||
if save:
|
||||
directory = generated / key
|
||||
directory.mkdir()
|
||||
(directory / "input.xml").write_bytes(xml)
|
||||
(directory / "model.c").write_text(program.source, encoding="utf-8")
|
||||
(directory / "model.h").write_text(program.header, encoding="utf-8")
|
||||
write_json(directory / "contract.json", contract)
|
||||
write_json(directory / "preparation.json", {**record, "generationSeconds": preparation})
|
||||
programs[key], records[key] = program, record
|
||||
if programs["a"].header != programs["a_prime"].header or programs["a"].state_keys != programs["a_prime"].state_keys:
|
||||
raise RuntimeError("Length variant changed model header/state layout")
|
||||
if programs["a"].source == programs["a_prime"].source:
|
||||
raise RuntimeError("Length variant did not change generated C")
|
||||
if not save and records != config["generated"][version]:
|
||||
raise RuntimeError("Regenerated model differs from prepared artifacts")
|
||||
assert_source(source)
|
||||
return builder, programs, records
|
||||
|
||||
|
||||
def cache_size(cache: Path) -> dict:
|
||||
files = [p for p in cache.rglob("*") if p.is_file()]
|
||||
return {"logicalFileBytes": sum(p.stat().st_size for p in files), "fileCount": len(files),
|
||||
"modelsBytes": sum(p.stat().st_size for p in files if p.relative_to(cache).parts[0] == "models"),
|
||||
"objectsBytes": sum(p.stat().st_size for p in files if p.relative_to(cache).parts[0] == "objects")}
|
||||
|
||||
|
||||
def worker(args) -> None:
|
||||
config = json.loads(args.worker_config.read_text())
|
||||
if digest(SCRIPT) != config["scriptSha256"]:
|
||||
raise RuntimeError("Benchmark script changed after preparation")
|
||||
if {key: os.environ.get(key) for key in ENVIRONMENT} != config["environment"]:
|
||||
raise RuntimeError("Build environment changed after preparation")
|
||||
builder, programs, records = generate(config, args.version, save=args.worker == "prepare")
|
||||
if args.worker == "prepare":
|
||||
write_json(Path(config["outputDir"]) / args.version / "prepared-models.json", records)
|
||||
return
|
||||
directory = Path(config["outputDir"]) / args.version / args.label
|
||||
directory.mkdir()
|
||||
cache = directory / "cache"
|
||||
if cache.exists():
|
||||
raise RuntimeError("Cold build requires a previously nonexistent cache directory")
|
||||
rows = []
|
||||
for scenario, model, expected_hit in SCENARIOS:
|
||||
case = directory / scenario
|
||||
case.mkdir()
|
||||
before = time.perf_counter()
|
||||
built = builder.build_native(programs[model], cache_dir=cache)
|
||||
returned = time.perf_counter()
|
||||
release_start = time.perf_counter()
|
||||
if hasattr(built, "close"):
|
||||
built.close()
|
||||
released = time.perf_counter()
|
||||
manifest = built.manifest
|
||||
copied_manifest = case / "build-manifest.json"
|
||||
write_json(copied_manifest, manifest)
|
||||
details = getattr(built, "details", {})
|
||||
size = cache_size(cache)
|
||||
row = {"version": args.version, "run": args.label, "warmup": args.label.startswith("warmup-"),
|
||||
"scenario": scenario, "model": model, "buildSeconds": built.seconds,
|
||||
"buildCallWallSeconds": returned - before, "releaseWallSeconds": released - release_start,
|
||||
"buildAndReleaseWallSeconds": released - before, "cacheHit": built.cache_hit,
|
||||
"buildKey": manifest["buildKey"], "details": details,
|
||||
"executable": str(built.executable), "executableSha256": digest(built.executable),
|
||||
"executableBytes": built.executable.stat().st_size, "cacheBytes": size["logicalFileBytes"], "cacheSize": size,
|
||||
"sourceFilesSha256": config["versions"][args.version]["sourceFilesSha256"],
|
||||
"generated": records[model], "compiler": manifest["compiler"], "compilerFlags": manifest["compilerFlags"],
|
||||
"dependencyHashes": manifest["dependencyHashes"], "sourceHashes": manifest["sourceHashes"],
|
||||
"nativeHeaderHashes": manifest.get("nativeHeaderHashes"),
|
||||
"manifest": str(copied_manifest.relative_to(Path(config["outputDir"]))), "manifestSha256": digest(copied_manifest)}
|
||||
row["checks"] = {"expectedCacheHit": built.cache_hit == expected_hit,
|
||||
"parameterChangeReusesOtherObjects": (details.get("objectCompilations") == 1 and
|
||||
details.get("objectCacheHits") == details.get("unitCount", 0) - 1)
|
||||
if scenario == "parameter_change" and "objectCompilations" in details else None}
|
||||
write_json(case / "measurement.json", row)
|
||||
rows.append(row)
|
||||
if any(value is False for value in row["checks"].values()):
|
||||
raise RuntimeError(f"Unexpected cache behavior in {args.version}/{args.label}/{scenario}; measurement preserved")
|
||||
assert_source(config["versions"][args.version])
|
||||
write_json(directory / "measurements.json", {"complete": True, "solverExecuted": False, "rows": rows})
|
||||
|
||||
|
||||
def invoke(config: dict, mode: str, version: str, label: str | None = None) -> dict:
|
||||
output = Path(config["outputDir"])
|
||||
command = [sys.executable, str(SCRIPT), "--worker", mode, "--worker-config", str(output / "prepared.json"), "--version", version]
|
||||
if label:
|
||||
command += ["--label", label]
|
||||
log = output / "logs" / (version + "-" + (label or mode))
|
||||
start = time.perf_counter()
|
||||
with log.with_suffix(".stdout.log").open("wb") as stdout, log.with_suffix(".stderr.log").open("wb") as stderr:
|
||||
process = subprocess.run(command, cwd=config["versions"][version]["root"], stdout=stdout, stderr=stderr, timeout=config["workerTimeout"])
|
||||
observed = {"command": command, "processWallSeconds": time.perf_counter() - start, "exitCode": process.returncode,
|
||||
"version": version, "run": label, "scope": "Whole worker including import/generation/checks/recording; not build latency."}
|
||||
write_json(log.with_suffix(".process.json"), observed)
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"Worker failed ({process.returncode}); see {log.with_suffix('.stderr.log')}")
|
||||
return observed
|
||||
|
||||
|
||||
def stat(values: list) -> dict:
|
||||
return {"n": len(values), "min": min(values), "median": statistics.median(values), "max": max(values)} if values else {"n": 0, "min": None, "median": None, "max": None}
|
||||
|
||||
|
||||
def metric(row: dict, name: str):
|
||||
return row[name] if name in row else row["details"].get(name)
|
||||
|
||||
|
||||
def summarize(config: dict, rows: list, processes: list) -> dict:
|
||||
expected = 2 * (config["warmups"] + config["repeats"]) * len(SCENARIOS)
|
||||
if len(rows) != expected:
|
||||
raise RuntimeError(f"Incomplete benchmark: expected {expected} rows, found {len(rows)}")
|
||||
compilers = {(r["compiler"], tuple(r["compilerFlags"])) for r in rows}
|
||||
libraries = {json_digest({k: v for k, v in r["dependencyHashes"].items() if "/" not in k}) for r in rows}
|
||||
if len(compilers) != 1 or len(libraries) != 1:
|
||||
raise RuntimeError("Compiler/flags or linked library bytes differ across benchmark runs")
|
||||
statistics_by_version = {}
|
||||
for version in config["versions"]:
|
||||
statistics_by_version[version] = {}
|
||||
for scenario, _, _ in SCENARIOS:
|
||||
selected = [r for r in rows if r["version"] == version and r["scenario"] == scenario and not r["warmup"]]
|
||||
if len(selected) != config["repeats"]:
|
||||
raise RuntimeError(f"Incomplete formal group: {version}/{scenario}")
|
||||
statistics_by_version[version][scenario] = {key: stat([metric(r, key) for r in selected if metric(r, key) is not None]) for key in METRICS}
|
||||
changes = {}
|
||||
for scenario, _, _ in SCENARIOS:
|
||||
changes[scenario] = {}
|
||||
for key in ("buildSeconds", "buildCallWallSeconds", "buildAndReleaseWallSeconds"):
|
||||
old = statistics_by_version["baseline"][scenario][key]["median"]
|
||||
new = statistics_by_version["candidate"][scenario][key]["median"]
|
||||
changes[scenario][key] = {"baselineMedian": old, "candidateMedian": new,
|
||||
"reductionPercent": 100 * (1 - new / old) if old else None,
|
||||
"speedup": old / new if new else None, "method": "ratio of group medians"}
|
||||
return {"complete": True, "solverExecuted": False, "prepared": "prepared.json", "scriptSha256": config["scriptSha256"],
|
||||
"contract": config["contract"], "checks": {"compilerAndFlagsIdentical": True, "linkedLibrariesIdentical": True,
|
||||
"generatedModelsIdenticalAcrossVersions": True, "variantHeaderAndStateLayoutUnchanged": True},
|
||||
"statistics": statistics_by_version, "changes": changes, "rows": rows, "workers": processes}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--baseline-root", type=Path, default=ROOT / "test/incremental-build-20260912/baseline-source")
|
||||
parser.add_argument("--candidate-root", type=Path, default=ROOT)
|
||||
parser.add_argument("--input", type=Path, default=ROOT / "tests/data/test-mql-8-corrected.json")
|
||||
parser.add_argument("--four-input", type=Path, default=ROOT / "tests/data/test-mql-4-corrected.json")
|
||||
parser.add_argument("--variant-node", default="amesim_pnl0001_1")
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--repeats", type=int, default=3)
|
||||
parser.add_argument("--worker-timeout", type=float, default=300)
|
||||
parser.add_argument("--run", action="store_true")
|
||||
parser.add_argument("--worker", choices=("prepare", "round"), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--worker-config", type=Path, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--version", choices=("baseline", "candidate"), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--label", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.worker:
|
||||
if not args.worker_config or not args.version or (args.worker == "round" and not args.label):
|
||||
parser.error("Internal worker arguments are incomplete")
|
||||
worker(args)
|
||||
return
|
||||
if not args.output_dir or args.warmups < 1 or args.repeats < 1 or not math.isfinite(args.worker_timeout) or args.worker_timeout <= 0:
|
||||
parser.error("A fresh --output-dir, warmups/repeats >= 1 and a positive worker timeout are required")
|
||||
output = args.output_dir.resolve()
|
||||
if not output.is_relative_to(ROOT / "test") or output.exists():
|
||||
parser.error("Choose a previously nonexistent directory under the ignored test/ tree")
|
||||
versions = {"baseline": source_record(args.baseline_root.resolve()), "candidate": source_record(args.candidate_root.resolve())}
|
||||
output.mkdir(parents=True)
|
||||
(output / "logs").mkdir()
|
||||
try:
|
||||
inputs, change = make_inputs(args, output)
|
||||
config = {"createdAtUtc": datetime.now(timezone.utc).isoformat(), "outputDir": str(output), "versions": versions,
|
||||
"script": str(SCRIPT), "scriptSha256": digest(SCRIPT), "python": sys.version, "pythonExecutable": sys.executable,
|
||||
"platform": platform.platform(), "cpuCount": os.cpu_count(), "environment": {key: os.environ.get(key) for key in ENVIRONMENT},
|
||||
"inputs": inputs, "variant": change, "warmups": args.warmups, "repeats": args.repeats,
|
||||
"workerTimeout": args.worker_timeout, "runRequested": args.run,
|
||||
"contract": {"cold": "Independent empty application cache per version/round; OS caches are not flushed.",
|
||||
"sequence": [name for name, _, _ in SCENARIOS], "solverExecuted": False,
|
||||
"fourBranchCase": "Switch from the full eight-branch project to the corrected four-branch project, then restore A; no cached files are manually deleted.",
|
||||
"wallTiming": "Builder call and adjacent release are measured separately; generation, hashing, imports and recording are outside these spans.",
|
||||
"overlap": "buildSeconds is inside buildCallWallSeconds, which is inside buildAndReleaseWallSeconds. Detail spans are nested; compileSeconds sums concurrent TU walls and is not additive to compileWallSeconds.",
|
||||
"unavailable": "Old builder has no object/stage details; absent measurements are null, never inferred from differences.",
|
||||
"comparison": "Formal group-median ratios; warmups excluded. No old strace timings or solver performance claims."}}
|
||||
write_json(output / "prepared.json", config)
|
||||
for version in versions:
|
||||
invoke(config, "prepare", version)
|
||||
config["generated"] = {version: json.loads((output / version / "prepared-models.json").read_text()) for version in versions}
|
||||
if config["generated"]["baseline"] != config["generated"]["candidate"]:
|
||||
raise RuntimeError("Generated model/XML/header/contract differs between source versions")
|
||||
write_json(output / "prepared.json", config)
|
||||
if not args.run:
|
||||
print(json.dumps({"preparedOnly": True, "outputDir": str(output), "compiled": False, "solverExecuted": False}))
|
||||
return
|
||||
rows, processes = [], []
|
||||
for index in range(args.warmups + args.repeats):
|
||||
label = f"warmup-{index + 1}" if index < args.warmups else f"run-{index - args.warmups + 1}"
|
||||
order = ("baseline", "candidate") if index % 2 == 0 else ("candidate", "baseline")
|
||||
for version in order:
|
||||
processes.append(invoke(config, "round", version, label))
|
||||
rows.extend(json.loads((output / version / label / "measurements.json").read_text())["rows"])
|
||||
for record in versions.values():
|
||||
assert_source(record)
|
||||
summary = summarize(config, rows, processes)
|
||||
write_json(output / "summary.json", summary)
|
||||
with (output / "timings.csv").open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=["version", "run", "warmup", "scenario", "metric", "value", "n", "min", "median", "max"])
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
for key in METRICS:
|
||||
writer.writerow({**{k: row[k] for k in ("version", "run", "warmup", "scenario")}, "metric": key, "value": metric(row, key)})
|
||||
for version, groups in summary["statistics"].items():
|
||||
for scenario, values in groups.items():
|
||||
for key, value in values.items():
|
||||
writer.writerow({"version": version, "run": "formal-statistics", "warmup": False, "scenario": scenario, "metric": key, **value})
|
||||
print(json.dumps({"complete": True, "summary": str(output / "summary.json"), "solverExecuted": False}))
|
||||
except Exception as exc:
|
||||
write_json(output / "failure.json", {"complete": False, "error": str(exc), "type": type(exc).__name__, "solverExecuted": False})
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user