"""Operational speed check for the Cause Test. It is not part of the plan, the lock or any result, and it changes none
of them.

It runs the locked base-run code (cause_test.base_run, unchanged) on old start 1 only, whose long run has been saved
since Test 17: no new start, no branch, no change of coherence, and nothing the run computes is kept. WORKERS
processes run it at once for up to CAP seconds, the way a base piece runs its base runs at once. The script measures
how fast each process goes, how much processor time it actually got, and what the machine was doing (busy, idle and
stolen processor time from /proc/stat). With --pairs, worker i is kept to the first processor of the i-th pair that
shares its L1 and L2 caches (0-1, 2-3, 4-5, 6-7), so no two workers share those caches.

Only timings are written, to cause_operations_bench.json.

    python3 -B cause_sustained_bench.py WORKERS [CAP] [--pairs]
"""
import cause_test as ct  # noqa: I001  (first: one BLAS thread per process, set before numpy loads, as in the run)
import json
import multiprocessing as mp
import os
import queue as queues
import sys
import time

OUT = ct.HERE / "cause_operations_bench.json"
PAIRS = ((0, 1), (2, 3), (4, 5), (6, 7))      # processors sharing L1 and L2 (/sys/devices/system/cpu/cpu*/cache)
FULL = ct.RUN                                  # a base run: 300 time units of settling, then these 6,000
BRANCH_UNITS = 6 * ct.AFTER                    # a moment task: six branches of 500 time units, stepped the same way
WINDOW = 60.0                                  # seconds, for the slowest and the latest stretch


class Stop(Exception):
    pass


def worker(index, cap, cpus, out):
    if cpus:
        os.sched_setaffinity(0, cpus)
    marks = []
    clock, used = time.time(), time.process_time()

    def visit(t, us, uf):                      # called after every put-back (every 5 time units); reads clocks only
        if t == 5 or t % 50 == 0:
            marks.append((t, time.time() - clock, time.process_time() - used))
        if time.time() - clock > cap:
            raise Stop

    try:
        ct.base_run(0, run=FULL, visit=visit)
        done = True
    except Stop:
        done = False
    out.put(dict(worker=index, cpus=sorted(os.sched_getaffinity(0)), done=done, marks=marks,
                 wall=time.time() - clock, cpu=time.process_time() - used))


def proc_stat():
    rows = {}
    with open("/proc/stat") as handle:
        for line in handle:
            if line.startswith("cpu"):
                name, *values = line.split()
                user, nice, system, idle, iowait, irq, softirq, steal = (int(v) for v in values[:8])
                rows[name] = (user + nice + system + irq + softirq, idle + iowait, steal)
    return rows


def where(pid):
    """The processor a process last ran on (field 39 of /proc/PID/stat)."""
    try:
        with open(f"/proc/{pid}/stat") as handle:
            return int(handle.read().rsplit(")", 1)[1].split()[36])
    except (OSError, IndexError, ValueError):
        return None


def shares(a, b, name="cpu"):
    busy, idle, steal = (y - x for x, y in zip(a[name], b[name]))
    total = max(busy + idle + steal, 1)
    return dict(busy=100 * busy / total, idle=100 * idle / total, steal=100 * steal / total,
                stolen_of_wanted=100 * steal / max(busy + steal, 1))


def rate_between(marks, k, j):
    return (marks[j][0] - marks[k][0]) / (marks[j][1] - marks[k][1])


def summary(res):
    m = res["marks"]
    rate = rate_between(m, 0, len(m) - 1)
    stretches = []
    for j in range(len(m)):
        k = max((k for k in range(j) if m[j][1] - m[k][1] >= WINDOW), default=None)
        if k is not None:
            stretches.append(rate_between(m, k, j))
    slowest = min(stretches, default=rate)
    latest = stretches[-1] if stretches else rate
    full = res["wall"] if res["done"] else m[-1][1] + (FULL - m[-1][0]) / latest
    full_slowest = res["wall"] if res["done"] else m[-1][1] + (FULL - m[-1][0]) / slowest
    return dict(worker=res["worker"], done=res["done"], reached=m[-1][0], seconds=res["wall"], settle=m[0][1],
                rate=rate, slowest=slowest, latest=latest,
                cpu_share=(m[-1][2] - m[0][2]) / (m[-1][1] - m[0][1]),
                cpu_ms_per_unit=1000 * (m[-1][2] - m[0][2]) / (m[-1][0] - m[0][0]),
                full=full, full_slowest=full_slowest)


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    if not args:
        sys.exit(__doc__)
    workers, cap, pairs = int(args[0]), float(args[1]) if len(args) > 1 else 240.0, "--pairs" in sys.argv
    if pairs and workers > len(PAIRS):
        sys.exit("--pairs allows at most 4 workers")
    ct.unchanged()                             # the lock and every fingerprint, as a piece checks them
    ct.frozen()
    log_sha = ct.sha256(ct.RUNLOG) if ct.RUNLOG.exists() else None
    ctx = mp.get_context("fork")
    out = ctx.Queue()
    procs = [ctx.Process(target=worker, args=(i, cap, {PAIRS[i][0]} if pairs else None, out), daemon=True)
             for i in range(workers)]
    began, start = ct.stamp(), proc_stat()
    clock = time.time()
    for p in procs:
        p.start()
    results, windows, placed = [], [], []
    last, last_time = start, clock
    while len(results) < workers:
        try:
            results.append(out.get(timeout=1.0))
        except queues.Empty:
            pass
        if time.time() - last_time >= 5.0:
            now = proc_stat()
            windows.append(dict(t=round(time.time() - clock, 1), **shares(last, now)))
            placed.append([where(p.pid) for p in procs])
            last, last_time = now, time.time()
    for p in procs:
        p.join()
    end = proc_stat()
    whole = shares(start, end)
    per_cpu = {name: round(shares(start, end, name)["steal"], 1) for name in start if name != "cpu"}
    rows = sorted((summary(r) for r in results), key=lambda s: s["worker"])
    doubled = [sum(1 for pair in range(4) if sum(1 for c in sample if c is not None and c // 2 == pair) >= 2)
               for sample in placed]
    slowest_rate = min(s["slowest"] for s in rows)
    trial = dict(began=began, workers=workers, pairs=pairs, cap=cap, workload="cause_test.base_run on old start 1",
                 rows=rows, machine=whole, steal_by_cpu=per_cpu, windows=windows,
                 doubled_pairs=sum(doubled) / max(len(doubled), 1),
                 total_rate=sum(s["rate"] for s in rows),
                 base_piece=max(s["full"] for s in rows), base_piece_slowest=max(s["full_slowest"] for s in rows),
                 moment_task=BRANCH_UNITS / slowest_rate + 2.0,
                 runlog_unchanged=(ct.sha256(ct.RUNLOG) if ct.RUNLOG.exists() else None) == log_sha)
    data = json.loads(OUT.read_text()) if OUT.exists() else dict(note=__doc__.split("\n\n")[0], trials=[])
    data["trials"].append(trial)
    ct.write_json(OUT, data)

    print(f"{workers} worker(s) at once{' (one per cache pair)' if pairs else ''}, began {ct.shown(began)}, cap {cap:.0f} s")
    for s in rows:
        print(f"  worker {s['worker']}: {'finished' if s['done'] else 'stopped at cap'} at t = {s['reached']}"
              f" ({s['seconds']:.0f} s); settling {s['settle']:.1f} s; {s['rate']:.1f} units/s (slowest minute"
              f" {s['slowest']:.1f}); processor share {s['cpu_share']:.2f}; {s['cpu_ms_per_unit']:.1f} ms of processor"
              f" per unit; full base run {s['full']:.0f} s (at its slowest minute {s['full_slowest']:.0f} s)")
    steal = sorted(w["steal"] for w in windows)
    print(f"  machine: busy {whole['busy']:.0f}%, idle {whole['idle']:.0f}%, stolen {whole['steal']:.0f}%"
          f" (stolen per 5 s: median {steal[len(steal) // 2]:.0f}%, most {steal[-1]:.0f}%);"
          f" {whole['stolen_of_wanted']:.0f}% of the processor time asked for was stolen")
    print(f"  pairs holding two workers (mean of 5-s samples): {trial['doubled_pairs']:.1f};"
          f" all workers together {trial['total_rate']:.1f} units/s")
    print(f"  base piece at this load (one base run per worker): {trial['base_piece']:.0f} s"
          f" (from each worker's slowest minute {trial['base_piece_slowest']:.0f} s); moment task {trial['moment_task']:.0f} s,"
          f" two rounds {2 * trial['moment_task']:.0f} s; run log unchanged {trial['runlog_unchanged']}")


if __name__ == "__main__":
    main()
