Artifex/gpu_batch01_v1_1_runner.py

373 lines
14 KiB
Python
Raw Permalink Normal View History

"""Reproducible GPU Batch01 V1.1 evidence runner for the ARM64 CUDA image.
The runner deliberately evaluates the CPU historical port and the CUDA engine
separately. CUDA unavailability is an error: GPU results are never replaced by
CPU results.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import platform
import sys
import time
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import numpy as np
import torch
from gpu_feature_engine_v1 import evaluate_batch, psar_trace, supertrend_trace
from gpu_feature_parity_contract_v1_1 import (
calibrate,
canonical_bytes,
compare_stateful_trace,
corpus_manifest,
cpu_psar_trace,
cpu_supertrend_trace,
deterministic_adversarial_ohlcv,
freeze_contract,
historical_cpu_oracle,
nan_gap_semantics_manifest,
validate_frozen_contract,
)
ARTIFACT_PREFIX = "gpu_batch01_v1_1"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_json(path: Path, payload: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(canonical_bytes(dict(payload)) + b"\n")
def read_ohlcv(path: Path) -> dict[str, np.ndarray]:
with path.open(newline="", encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
required = ("close", "high", "low", "volume")
if not rows or not set(required).issubset(rows[0]):
raise ValueError("CSV must contain non-empty close, high, low, volume columns")
values = {
name: np.asarray([float(row[name]) for row in rows], dtype=np.float64) for name in required
}
if not all(np.isfinite(value).all() for value in values.values()):
raise ValueError(
"historical NaN/gap semantics are undefined; non-finite OHLCV inputs are rejected"
)
return values
def write_ohlcv_csv(path: Path, ohlcv: Mapping[str, np.ndarray]) -> None:
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["close", "high", "low", "volume"])
writer.writeheader()
writer.writerows(
{name: float(ohlcv[name][index]) for name in writer.fieldnames}
for index in range(len(ohlcv["close"]))
)
def gpu_outputs(
request: Mapping[str, Any], ohlcv: Mapping[str, np.ndarray], device: torch.device
) -> tuple[dict[str, np.ndarray], dict[str, float]]:
torch.cuda.reset_peak_memory_stats(device)
transfer_start = time.perf_counter()
tensors = tuple(
torch.as_tensor(ohlcv[name], dtype=torch.float64, device=device)
for name in ("close", "high", "low", "volume")
)
torch.cuda.synchronize(device)
transfer_seconds = time.perf_counter() - transfer_start
compute_start = time.perf_counter()
outputs = evaluate_batch(dict(request), *tensors)
torch.cuda.synchronize(device)
compute_seconds = time.perf_counter() - compute_start
host_start = time.perf_counter()
result = {key: value.detach().cpu().numpy() for key, value in outputs.items()}
torch.cuda.synchronize(device)
return result, {
"host_to_device_seconds": transfer_seconds,
"compute_seconds": compute_seconds,
"device_to_host_seconds": time.perf_counter() - host_start,
"max_gpu_memory_bytes": int(torch.cuda.max_memory_allocated(device)),
}
def traces(
requests: list[Mapping[str, Any]], ohlcv: Mapping[str, np.ndarray], device: torch.device
) -> dict[str, Any]:
close, high, low = (ohlcv[name] for name in ("close", "high", "low"))
gpu_values = tuple(
torch.as_tensor(value, dtype=torch.float64, device=device) for value in (close, high, low)
)
records = []
for item in requests:
indicator = int(item["indicator_id"])
if indicator == 19:
expected = cpu_supertrend_trace(
close, high, low, int(item["period"]), float(item["p1"])
)
actual = supertrend_trace(*gpu_values, int(item["period"]), float(item["p1"]))
trace_type = "supertrend"
elif indicator == 28:
expected = cpu_psar_trace(close, high, low, float(item["p1"]))
actual = psar_trace(*gpu_values, float(item["p1"]))
trace_type = "psar"
else:
continue
torch.cuda.synchronize(device)
compared = compare_stateful_trace(
expected,
{key: value.detach().cpu().numpy() for key, value in actual.items()},
trace_type=trace_type,
)
records.append({"request_id": str(item["request_id"]), "family": trace_type, **compared})
return {
"artifact": "GPU_BATCH01_STATEFUL_TRACE_COMPARISON_V1_1",
"passed": all(item["exact"] for item in records),
"records": records,
}
def performance(
request: Mapping[str, Any], ohlcv: Mapping[str, np.ndarray], device: torch.device
) -> dict[str, Any]:
# Each family is measured through the same CUDA evaluate_batch entry point.
families: dict[str, list[Mapping[str, Any]]] = {}
names = {
17: "bollinger",
18: "bollinger",
19: "supertrend",
20: "donchian",
21: "donchian",
23: "keltner",
24: "keltner",
28: "psar",
}
for item in request["requests"]:
families.setdefault(names[int(item["indicator_id"])], []).append(item)
results = []
for family, items in sorted(families.items()):
family_request = {"requests": items}
_, cold = gpu_outputs(family_request, ohlcv, device)
_, warm = gpu_outputs(family_request, ohlcv, device)
values = len(ohlcv["close"]) * len(items)
results.append(
{
"family": family,
"variants": len(items),
"values": values,
"cold": cold,
"warm": warm,
"cold_values_per_second": values / cold["compute_seconds"]
if cold["compute_seconds"]
else None,
"warm_values_per_second": values / warm["compute_seconds"]
if warm["compute_seconds"]
else None,
}
)
return {
"artifact": "GPU_BATCH01_PERFORMANCE_METRICS_V1_1",
"measurement": (
"CUDA synchronize before each interval; cold is first family invocation, warm is second"
),
"families": results,
}
def role_semantics(path: Path | None) -> dict[str, Any]:
if path is None:
return {
"status": "not_reconstructable",
"reason": "Batch01 request records contain no strategy-role assignment; provide "
"--role-usage-artifact generated from historical lineage.",
}
if not path.is_file():
raise ValueError(f"role usage artifact does not exist: {path}")
return {"status": "source_artifact_recorded", "path": str(path), "sha256": sha256_file(path)}
def implementation_hashes() -> dict[str, str]:
root = Path(__file__).resolve().parent
return {
path.name: sha256_file(path)
for path in (
root / "gpu_batch01_v1_1_runner.py",
root / "gpu_feature_engine_v1.py",
root / "gpu_feature_parity_contract_v1_1.py",
)
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--calibration-csv", type=Path, required=True)
parser.add_argument("--holdout-csv", type=Path, required=True)
parser.add_argument("--request-json", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--adversarial-length", type=int, default=256)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--role-usage-artifact", type=Path)
parser.add_argument("--image-env", default=os.getenv("GPU_BATCH01_IMAGE_ENV"))
args = parser.parse_args()
if not torch.cuda.is_available():
raise SystemExit("CUDA GPU is required; no CPU fallback is available")
args.output_dir.mkdir(parents=True, exist_ok=True)
request = json.loads(args.request_json.read_text(encoding="utf-8"))
requests = request.get("requests")
if not isinstance(requests, list) or not requests:
raise ValueError("request JSON must contain a non-empty requests list")
device = torch.device("cuda")
# Calibration is the only stage allowed to derive and freeze acceptance limits.
calibration_ohlcv = read_ohlcv(args.calibration_csv)
calibration_cpu = historical_cpu_oracle(args.calibration_csv, requests)
calibration_gpu, calibration_timing = gpu_outputs(request, calibration_ohlcv, device)
calibration_manifest = corpus_manifest(
{"calibration": calibration_ohlcv},
{
"source_csv": str(args.calibration_csv),
"source_sha256": sha256_file(args.calibration_csv),
},
)
calibration_evidence = calibrate(
calibration_gpu,
calibration_cpu["outputs"],
requests,
calibration_ohlcv["close"],
calibration_manifest,
)
contract = freeze_contract(calibration_evidence)
write_json(args.output_dir / "gpu_feature_parity_calibration_v1_1.json", calibration_evidence)
write_json(args.output_dir / "gpu_feature_parity_contract_v1_1.json", contract)
np.savez_compressed(
args.output_dir / f"{ARTIFACT_PREFIX}_calibration_cpu_oracle.npz",
**calibration_cpu["outputs"],
)
np.savez_compressed(
args.output_dir / f"{ARTIFACT_PREFIX}_calibration_gpu_outputs.npz", **calibration_gpu
)
adversarial = deterministic_adversarial_ohlcv(length=args.adversarial_length, seed=args.seed)
adversarial_csv = args.output_dir / f"{ARTIFACT_PREFIX}_adversarial.csv"
write_ohlcv_csv(adversarial_csv, adversarial)
corpora = {
"calibration": calibration_ohlcv,
"holdout": read_ohlcv(args.holdout_csv),
"adversarial": adversarial,
}
corpus = corpus_manifest(
corpora,
{
"adversarial_generator": "deterministic_adversarial_ohlcv",
"seed": args.seed,
"role_semantics": role_semantics(args.role_usage_artifact),
},
)
write_json(args.output_dir / "gpu_feature_parity_corpus_manifest_v1_1.json", corpus)
write_json(
args.output_dir / "gpu_feature_parity_nan_gap_semantics_v1_1.json",
nan_gap_semantics_manifest(),
)
validations, trace_reports = [], []
for name in ("calibration", "holdout", "adversarial"):
if name == "calibration":
csv_path, cpu, gpu, timing = (
args.calibration_csv,
calibration_cpu,
calibration_gpu,
calibration_timing,
)
else:
csv_path = args.holdout_csv if name == "holdout" else adversarial_csv
cpu = historical_cpu_oracle(csv_path, requests)
gpu, timing = gpu_outputs(request, corpora[name], device)
validation = validate_frozen_contract(contract, gpu, cpu["outputs"], corpora[name]["close"])
validation.update(
{
"corpus": name,
"cpu_oracle": {key: value for key, value in cpu.items() if key != "outputs"},
"gpu_timing": timing,
}
)
validations.append(validation)
trace_reports.append({"corpus": name, **traces(requests, corpora[name], device)})
if name != "calibration":
np.savez_compressed(
args.output_dir / f"{ARTIFACT_PREFIX}_{name}_cpu_oracle.npz", **cpu["outputs"]
)
np.savez_compressed(
args.output_dir / f"{ARTIFACT_PREFIX}_{name}_gpu_outputs.npz", **gpu
)
validation_payload = {
"artifact": "GPU_FEATURE_PARITY_VALIDATION_REPORT_V1_1",
"contract_sha256": hashlib.sha256(canonical_bytes(contract)).hexdigest(),
"passed": all(value["passed"] for value in validations),
"corpora": validations,
}
trace_payload = {
"artifact": "GPU_BATCH01_STATEFUL_TRACE_REPORT_V1_1",
"passed": all(value["passed"] for value in trace_reports),
"corpora": trace_reports,
}
write_json(args.output_dir / "gpu_feature_parity_validation_v1_1.json", validation_payload)
write_json(args.output_dir / "gpu_batch01_stateful_trace_comparison_v1_1.json", trace_payload)
write_json(
args.output_dir / "gpu_batch01_performance_metrics_v1_1.json",
performance(request, corpora["holdout"], device),
)
root = Path(__file__).resolve().parent
manifest = {
"artifact": "GPU_BATCH01_V1_1_EVIDENCE_MANIFEST",
"schema_version": "1.1",
"python": sys.version,
"platform": platform.platform(),
"torch": torch.__version__,
"cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_properties(device).name,
"image_env": args.image_env,
"source_hashes": {
"historical_band_channel.py": sha256_file(
root / "control_plane/trading_studio/indicators/historical_band_channel.py"
)
},
"implementation_hashes": implementation_hashes(),
"data_hashes": {
"request_json": sha256_file(args.request_json),
"calibration_csv": sha256_file(args.calibration_csv),
"holdout_csv": sha256_file(args.holdout_csv),
"adversarial_csv": sha256_file(adversarial_csv),
},
"calibration_gpu_timing": calibration_timing,
"role_semantics": role_semantics(args.role_usage_artifact),
}
write_json(args.output_dir / "gpu_batch01_v1_1_evidence_manifest.json", manifest)
print(
json.dumps(
{
"output_dir": str(args.output_dir),
"validation_passed": validation_payload["passed"],
"trace_passed": trace_payload["passed"],
},
allow_nan=False,
)
)
if __name__ == "__main__":
main()