#!/usr/pkg/bin/python3.13
# pylint: disable=C0103,C0114,C0116,C0209,C0301,R0914,R0912,R0915,W0511,W0603,eval-used
######################################################################

import argparse
import bisect
import collections
import math
import re
import statistics
from collections import OrderedDict
# from pprint import pprint

Sections = OrderedDict()
LongestVcdStrValueLength = 0
Threads = collections.defaultdict(lambda: [])  # List of records per thread id
Mtasks = collections.defaultdict(lambda: {'elapsed': 0, 'end': 0})
Cpus = collections.defaultdict(lambda: {'mtask_time': 0})
Global = {
    'args': {},
    'cpuinfo': collections.defaultdict(lambda: {}),
    'info': {
        'numa': 'no data'
    },
    'stats': {}
}
ElapsedTime = None  # total elapsed time
ExecGraphTime = 0  # total elapsed time executing an exec graph
WaitingTime = 0  # total elapsed time waiting for mtasks
ExecGraphIntervals = []  # list of (start, end) pairs
ThreadScheduleWaitIntervals = []  # list of (start, tick, ecpu) pairs

######################################################################


def read_data(filename: str) -> None:
    with open(filename, "r", encoding="utf8") as fh:
        re_thread = re.compile(r'^VLPROFTHREAD (\d+)$')
        re_record = re.compile(r'^VLPROFEXEC (\S+) (\d+)(.*)$')
        # The hierBlock argument is optional
        re_payload_mtaskBegin = re.compile(
            r'id (\d+) predictStart (\d+) cpu (\d+)(?: hierBlock)?\s*(\w+)?')
        re_payload_mtaskEnd = re.compile(r'predictCost (\d+)')
        re_payload_wait = re.compile(r'cpu (\d+)')

        re_arg1 = re.compile(r'VLPROF arg\s+(\S+)\+([0-9.]*)\s*')
        re_arg2 = re.compile(r'VLPROF arg\s+(\S+)\s+([0-9.]*)\s*$')
        re_info = re.compile(r'VLPROF info\s+(\S+)\s+(.*)$')
        re_stat = re.compile(r'VLPROF stat\s+(\S+)\s+(\S+)')
        re_proc_cpu = re.compile(r'VLPROFPROC processor\s*:\s*(\d+)\s*$')
        re_proc_dat = re.compile(r'VLPROFPROC ([a-z_ ]+)\s*:\s*(.*)$')
        cpu = None
        thread = 0
        thread_last_ecpu = None

        global LongestVcdStrValueLength
        global ExecGraphTime
        global WaitingTime

        ExecGraphStack = []
        SectionStack = []
        MtasksStack = []
        ThreadScheduleWait = collections.defaultdict(list)

        for line in fh:
            recordMatch = re_record.match(line)
            if recordMatch:
                kind, tick, payload = recordMatch.groups()
                tick = int(tick)
                payload = payload.strip()
                if kind == "SECTION_PUSH":
                    LongestVcdStrValueLength = max(LongestVcdStrValueLength, len(payload))
                    SectionStack.append(payload)
                    Sections[thread].append((tick, tuple(SectionStack)))
                elif kind == "SECTION_POP":
                    assert SectionStack, "SECTION_POP without SECTION_PUSH"
                    SectionStack.pop()
                    Sections[thread].append((tick, tuple(SectionStack)))
                elif kind == "MTASK_BEGIN":
                    mtask, predict_start, ecpu, hier_block = re_payload_mtaskBegin.match(
                        payload).groups()
                    hier_block = "" if hier_block is None else hier_block
                    mtask = int(mtask)
                    predict_start = int(predict_start)
                    ecpu = int(ecpu)
                    records = Threads[thread]
                    records.append({
                        'start': tick,
                        'mtask': mtask,
                        'predict_start': predict_start,
                        'hier_block': hier_block,
                        'cpu': ecpu
                    })
                    Mtasks[(hier_block, mtask)]['begin'] = tick
                    Mtasks[(hier_block, mtask)]['predict_start'] = predict_start
                    Mtasks[(hier_block, mtask)]['thread'] = thread
                    MtasksStack.append((hier_block, mtask, records[-1]))
                elif kind == "MTASK_END":
                    predict_cost, = re_payload_mtaskEnd.match(payload).groups()
                    mtask = int(mtask)
                    hier_block, mtask, record = MtasksStack.pop()
                    predict_cost = int(predict_cost)
                    begin = Mtasks[(hier_block, mtask)]['begin']
                    record['end'] = tick
                    assert record and records[-1]['start'] <= records[-1]['end'] <= tick
                    record['predict_cost'] = predict_cost
                    Mtasks[(hier_block, mtask)]['elapsed'] += tick - begin
                    Mtasks[(hier_block, mtask)]['predict_cost'] = predict_cost
                    Mtasks[(hier_block, mtask)]['end'] = max(Mtasks[(hier_block, mtask)]['end'],
                                                             tick)
                elif kind == "THREAD_SCHEDULE_WAIT_BEGIN":
                    ecpu = int(re_payload_wait.match(payload).groups()[0])
                    thread_last_ecpu = ecpu
                    ThreadScheduleWait[ecpu].append(tick)
                elif kind == "THREAD_SCHEDULE_WAIT_END":
                    # Might have ended on different CPU then we got THREAD_SCHEDULE_WAIT_BEGIN
                    assert thread_last_ecpu is not None, "THREAD_SCHEDULE_WAIT_END without BEGIN"
                    ecpu = thread_last_ecpu
                    thread_last_ecpu = None
                    start = ThreadScheduleWait[ecpu].pop()
                    WaitingTime += tick - start
                    ThreadScheduleWaitIntervals.append((start, tick, ecpu))
                elif kind == "EXEC_GRAPH_BEGIN":
                    ExecGraphStack.append(tick)
                elif kind == "EXEC_GRAPH_END":
                    assert ExecGraphStack, "EXEC_GRAPH_END without EXEC_GRAPH_BEGIN"
                    execGraphStart = ExecGraphStack.pop()
                    ExecGraphTime += tick - execGraphStart
                    ExecGraphIntervals.append((execGraphStart, tick))
                elif Args.debug:
                    print("-Unknown execution trace record: %s" % line)
            elif re_thread.match(line):
                thread = int(re_thread.match(line).group(1))
                Sections[thread] = []
            elif re.match(r'^VLPROF(THREAD|VERSION)', line):
                pass
            elif re_arg1.match(line):
                match = re_arg1.match(line)
                Global['args'][match.group(1)] = match.group(2)
            elif re_arg2.match(line):
                match = re_arg2.match(line)
                Global['args'][match.group(1)] = match.group(2)
            elif re_info.match(line):
                match = re_info.match(line)
                Global['info'][match.group(1)] = match.group(2)
            elif re_stat.match(line):
                match = re_stat.match(line)
                Global['stats'][match.group(1)] = match.group(2)
            elif re_proc_cpu.match(line):
                match = re_proc_cpu.match(line)
                cpu = int(match.group(1))
            elif cpu is not None and re_proc_dat.match(line):
                match = re_proc_dat.match(line)
                term = match.group(1)
                value = match.group(2)
                term = re.sub(r'\s+$', '', term)
                term = re.sub(r'\s+', '_', term)
                value = re.sub(r'\s+$', '', value)
                Global['cpuinfo'][cpu][term] = value
            elif re.match(r'^#', line):
                pass
            elif Args.debug:
                print("-Unk: %s" % line)


def re_match_result(regexp, line, result_to):
    result_to = re.match(regexp, line)
    return result_to


######################################################################


def report() -> None:
    print("Verilator Gantt report")

    print("\nArgument settings:")
    for arg in sorted(Global['args'].keys()):
        plus = "+" if re.match(r'^\+', arg) else " "
        print("  %s%s%s" % (arg, plus, Global['args'][arg]))

    for records in Threads.values():
        for record in records:
            cpu = record['cpu']
            elapsed = record['end'] - record['start']
            Cpus[cpu]['mtask_time'] += elapsed

    global ElapsedTime
    ElapsedTime = int(Global['stats']['ticks'])
    nthreads = int(Global['stats']['threads'])
    ncpus = max(len(Cpus), 1)

    print("\nSummary:")
    print("  Total elapsed time = {} rdtsc ticks".format(ElapsedTime))
    print("  Parallelized code  = {:.2%} of elapsed time".format(ExecGraphTime / ElapsedTime))
    print("  Waiting time       = {:.2%} of elapsed time".format(WaitingTime / ElapsedTime))
    print("  Total threads      = %d" % nthreads)
    print("  Total CPUs used    = %d" % ncpus)
    print("  Total mtasks       = %d" % len(Mtasks))
    print("  Total yields       = %d" % int(Global['stats'].get('yields', 0)))

    report_numa()
    report_mtasks()
    report_cpus()
    report_sections()

    if nthreads > ncpus:
        print()
        print("%%Warning: There were fewer CPUs (%d) than threads (%d)." % (ncpus, nthreads))
        print("        : See docs on use of numactl.")
    else:
        if 'cpu_socket_cores_warning' in Global:
            print()
            print("%Warning: Multiple threads scheduled on same hyperthreaded core.")
            print("        : See docs on use of numactl.")
        if 'cpu_sockets_warning' in Global:
            print()
            print("%Warning: Threads scheduled on multiple sockets.")
            print("        : See docs on use of numactl.")
    print()


def report_numa() -> None:
    print("\nNUMA assignment:")
    print("  NUMA status        = %s" % Global['info']['numa'])


def report_mtasks() -> None:
    if not Mtasks:
        return

    nthreads = int(Global['stats']['threads'])

    # If we know cycle time in the same (rdtsc) units,
    # this will give us an actual utilization number,
    # (how effectively we keep the cores busy.)
    #
    # It also gives us a number we can compare against
    # serial mode, to estimate the overhead of data sharing,
    # which will show up in the total elapsed time. (Overhead
    # of synchronization and scheduling should not.)
    total_mtask_time = 0
    thread_mtask_time = collections.defaultdict(lambda: 0)
    long_mtask_time = 0
    long_mtask = None
    long_mtask_hier_block = None
    predict_mtask_time = 0
    predict_elapsed = 0
    for (hier_block, mtask_id) in Mtasks:
        record = Mtasks[(hier_block, mtask_id)]
        predict_mtask_time += record['predict_cost']
        total_mtask_time += record['elapsed']
        thread_mtask_time[record['thread']] += record['elapsed']
        predict_end = record['predict_start'] + record['predict_cost']
        predict_elapsed = max(predict_elapsed, predict_end)
        if record['elapsed'] > long_mtask_time:
            long_mtask_time = record['elapsed']
            long_mtask = mtask_id
            long_mtask_hier_block = hier_block
    Global['predict_last_end'] = predict_elapsed

    serialTime = ElapsedTime - ExecGraphTime

    def subReport(elapsed, work):
        print("  Thread utilization = {:7.2%}".format(work / (elapsed * nthreads)))
        print("  Speedup            = {:6.3}x".format(work / elapsed))

    print("\nParallelized code, measured:")
    subReport(ExecGraphTime, total_mtask_time)

    print("\nParallelized code, predicted during static scheduling:")
    subReport(predict_elapsed, predict_mtask_time)

    print("\nAll code, measured:")
    subReport(ElapsedTime, serialTime + total_mtask_time)

    print("\nAll code, measured, scaled by predicted speedup:")
    expectedParallelSpeedup = predict_mtask_time / predict_elapsed
    scaledElapsed = serialTime + total_mtask_time / expectedParallelSpeedup
    subReport(scaledElapsed, serialTime + total_mtask_time)

    p2e_ratios = []
    min_p2e = 1000000
    min_mtask = None
    max_p2e = -1000000
    max_mtask = None

    min_hier_block = None
    max_hier_block = None

    for (hier_block, mtask_id) in sorted(Mtasks.keys()):
        mtask = Mtasks[(hier_block, mtask_id)]
        if mtask['elapsed'] > 0:
            if mtask['predict_cost'] == 0:
                mtask['predict_cost'] = 1  # don't log(0) below
            p2e_ratio = math.log(mtask['predict_cost'] / mtask['elapsed'])
            p2e_ratios.append(p2e_ratio)

            if p2e_ratio > max_p2e:
                max_p2e = p2e_ratio
                max_mtask = mtask_id
                max_hier_block = hier_block
            if p2e_ratio < min_p2e:
                min_p2e = p2e_ratio
                min_mtask = mtask_id
                min_hier_block = hier_block

    print("\nMTask statistics:")
    if long_mtask_hier_block:
        print("  Longest mtask id = {} from hier_block '{}'".format(long_mtask,
                                                                    long_mtask_hier_block))
    else:
        print("  Longest mtask id = {}".format(long_mtask))
    print("  Longest mtask time = {:.2%} of time elapsed in parallelized code".format(
        long_mtask_time / ExecGraphTime))
    print("  min log(p2e) = %0.3f" % min_p2e, end="")

    if min_hier_block:
        print("  from hier_block '%s' mtask %d (predict %d," %
              (min_hier_block, min_mtask, Mtasks[(min_hier_block, min_mtask)]['predict_cost']),
              end="")
    else:
        print("  from mtask %d (predict %d," %
              (min_mtask, Mtasks[(min_hier_block, min_mtask)]['predict_cost']),
              end="")
    print(" elapsed %d)" % Mtasks[(min_hier_block, min_mtask)]['elapsed'])
    print("  max log(p2e) = %0.3f" % max_p2e, end="")
    if max_hier_block:
        print("  from hier_block '%s' mtask %d (predict %d," %
              (max_hier_block, max_mtask, Mtasks[(max_hier_block, max_mtask)]['predict_cost']),
              end="")
    else:
        print("  from mtask %d (predict %d," %
              (max_mtask, Mtasks[(max_hier_block, max_mtask)]['predict_cost']),
              end="")
    print(" elapsed %d)" % Mtasks[(max_hier_block, max_mtask)]['elapsed'])

    stddev = statistics.pstdev(p2e_ratios)
    mean = statistics.mean(p2e_ratios)
    print("  mean = %0.3f" % mean)
    print("  stddev = %0.3f" % stddev)
    print("  e ^ stddev = %0.3f" % math.exp(stddev))


def report_cpus() -> None:
    print("\nCPU info:")

    Global['cpu_sockets'] = collections.defaultdict(lambda: 0)
    Global['cpu_socket_cores'] = collections.defaultdict(lambda: 0)

    print("   Id | Time spent executing MTask | Socket | Core | Model")
    print("      | % of elapsed ticks / ticks |        |      |")
    print("  ====|============================|========|======|======")
    for cpu in sorted(Cpus):
        socket = ""
        core = ""
        model = ""
        if cpu in Global['cpuinfo']:
            cpuinfo = Global['cpuinfo'][cpu]
            if 'physical_id' in cpuinfo and 'core_id' in cpuinfo:
                socket = cpuinfo['physical_id']
                Global['cpu_sockets'][socket] += 1
                core = cpuinfo['core_id']
                Global['cpu_socket_cores'][socket + "__" + core] += 1

            if 'model_name' in cpuinfo:
                model = cpuinfo['model_name']

        print("  {:3d} | {:7.2%} / {:16d} | {:>6s} | {:>4s} | {}".format(
            cpu, Cpus[cpu]['mtask_time'] / ElapsedTime, Cpus[cpu]['mtask_time'], socket, core,
            model))

    if len(Global['cpu_sockets']) > 1:
        Global['cpu_sockets_warning'] = True
        for scn in Global['cpu_socket_cores'].values():
            if scn > 1:
                Global['cpu_socket_cores_warning'] = True


def report_sections() -> None:
    for thread, section in Sections.items():
        if section:
            print(f"\nSection profile for thread {thread}:")
            report_section(section)


def report_section(section) -> None:
    totalTime = collections.defaultdict(lambda: 0)
    selfTime = collections.defaultdict(lambda: 0)

    sectionTree = [0, {}, 1]  # [selfTime, childTrees, numberOfTimesEntered]
    prevTime = 0
    prevStack = ()
    for time, stack in section:
        if len(stack) > len(prevStack):
            scope = sectionTree
            for item in stack:
                scope = scope[1].setdefault(item, [0, {}, 0])
            scope[2] += 1
        dt = time - prevTime
        assert dt >= 0
        scope = sectionTree
        for item in prevStack:
            scope = scope[1].setdefault(item, [0, {}, 0])
        scope[0] += dt

        if prevStack:
            for name in prevStack:
                totalTime[name] += dt
            selfTime[prevStack[-1]] += dt
        prevTime = time
        prevStack = stack

    def treeSum(tree):
        n = tree[0]
        for subTree in tree[1].values():
            n += treeSum(subTree)
        return n

    # Make sure the tree sums to the elapsed time
    sectionTree[0] += ElapsedTime - treeSum(sectionTree)

    def printTree(prefix, name, entries, tree):
        print("  {:7.2%} | {:7.2%} | {:8} | {:10.2f} | {}".format(
            treeSum(tree) / ElapsedTime, tree[0] / ElapsedTime, tree[2], tree[2] / entries,
            prefix + name))
        for k in sorted(tree[1], key=lambda _: -treeSum(tree[1][_])):
            printTree(prefix + "  ", k, tree[2], tree[1][k])

    print(" Total    | Self    | Total    | Relative   | Section")
    print(" time     | time    | entries  | entries    |  name  ")
    print("==========|=========|==========|============|========")
    printTree("", "*TOTAL*", 1, sectionTree)


######################################################################


def write_vcd(filename: str) -> None:
    print("Writing %s" % filename)
    with open(filename, "w", encoding="utf8") as fh:
        # dict of dicts of hierarchy elements/signal name  -> (code, width)
        scopeSigs = {}
        # map from time -> code -> value
        values = collections.defaultdict(lambda: {})

        parallelism = {
            'measured': collections.defaultdict(lambda: 0),
            'predicted': collections.defaultdict(lambda: 0)
        }
        parallelism['measured'][0] = 0
        parallelism['predicted'][0] = 0

        nextFreeCode = 0

        def getCode(width, *names):
            nonlocal nextFreeCode
            scope = scopeSigs
            *path, name = names
            for item in path:
                scope = scope.setdefault(item, {})
            code, oldWidth = scope.setdefault(name, (nextFreeCode, width))
            assert oldWidth == width
            if code == nextFreeCode:
                nextFreeCode += 1
            return code

        def addValue(code, time, val):
            if isinstance(val, str):
                buf = ""
                for c in val:
                    buf += bin(ord(c))[2:].rjust(8, "0")
                val = buf.ljust(LongestVcdStrValueLength * 8, "0")
            values[time][code] = val

        def addXValue(code, time):
            values[time][code] = 'x'

        # Measured graph
        for thread in sorted(Threads):
            mcode = getCode(32, 'measured', 't%d_mtask' % thread)
            for record in Threads[thread]:
                start = record['start']
                mtask = record['mtask']
                end = record['end']
                cpu = record['cpu']
                addValue(mcode, start, mtask)
                addValue(mcode, end, None)
                parallelism['measured'][start] += 1
                parallelism['measured'][end] -= 1

                code = getCode(32, 'measured', 'cpu%d_thread' % cpu)
                addValue(code, start, thread)
                addValue(code, end, None)

                code = getCode(32, 'measured', 'cpu%d_mtask' % cpu)
                addValue(code, start, mtask)
                addValue(code, end, None)

        tStart = sorted(_['start'] for records in Threads.values() for _ in records)
        tEnd = sorted(_['end'] for records in Threads.values() for _ in records)

        # Measured waiting time
        for (start, end, cpu) in ThreadScheduleWaitIntervals:
            mcode = getCode(32, 'measured', 'cpu%d_waiting' % cpu)
            addXValue(mcode, start)
            addValue(mcode, end, None)

        # Predicted graph
        for start, end in ExecGraphIntervals:
            # Find the earliest MTask start after the start point, and the
            # latest MTask end before the end point, so we can scale to the
            # same range
            tStartIdx = bisect.bisect_left(tStart, start)
            if tStartIdx >= len(tStart):
                continue
            start = tStart[tStartIdx]
            end = tEnd[bisect.bisect_right(tEnd, end) - 1]
            # Compute scale so predicted graph is of same width as interval
            measured_scaling = (end - start) / Global['predict_last_end']
            # Predict mtasks that fill the time the execution occupied
            for (hier_block, mtask_id) in Mtasks:
                mtask = Mtasks[(hier_block, mtask_id)]
                thread = mtask['thread']
                pred_scaled_start = start + int(mtask['predict_start'] * measured_scaling)
                pred_scaled_end = start + int(
                    (mtask['predict_start'] + mtask['predict_cost']) * measured_scaling)
                if pred_scaled_start == pred_scaled_end:
                    continue

                mcode = getCode(32, 'predicted', 't%d_mtask' % thread)
                addValue(mcode, pred_scaled_start, mtask_id)
                addValue(mcode, pred_scaled_end, None)

                parallelism['predicted'][pred_scaled_start] += 1
                parallelism['predicted'][pred_scaled_end] -= 1

        # Parallelism graph
        for measpred in ('measured', 'predicted'):
            pcode = getCode(32, 'stats', '%s_parallelism' % measpred)
            value = 0
            for time in sorted(parallelism[measpred].keys()):
                value += parallelism[measpred][time]
                addValue(pcode, time, value)

        # Section graph
        for thread, section in Sections.items():
            if section:
                scode = getCode(LongestVcdStrValueLength * 8, "section", f"t{thread}_trace")
                dcode = getCode(32, "section", f"t{thread}_depth")
                for time, stack in section:
                    addValue(scode, time, stack[-1] if stack else None)
                    addValue(dcode, time, len(stack))

        # Create output file
        fh.write("$version Generated by verilator_gantt $end\n")
        fh.write("$timescale 1ns $end\n")
        fh.write("\n")

        all_codes = set()

        def writeScope(scope):
            assert isinstance(scope, dict)
            for key in sorted(scope):
                val = scope[key]
                if isinstance(val, dict):
                    fh.write("  $scope module %s $end\n" % key)
                    writeScope(val)
                    fh.write("  $upscope $end\n")
                else:
                    (code, width) = val
                    all_codes.add(code)
                    fh.write("   $var wire %d v%x %s [%d:0] $end\n" %
                             (width, code, key, width - 1))

        fh.write(" $scope module gantt $end\n")
        writeScope(scopeSigs)
        fh.write(" $upscope $end\n")
        fh.write("$enddefinitions $end\n")
        fh.write("\n")

        first = True
        for time in sorted(values):
            if first:
                first = False
                # Start with Z for any signals without time zero data
                for code in sorted(all_codes):
                    if code not in values[time]:
                        addValue(code, time, None)
            fh.write("#%d\n" % time)
            for code in sorted(values[time]):
                value = values[time][code]
                if value is None:
                    fh.write("bz v%x\n" % code)
                elif value == 'x':
                    fh.write("bx v%x\n" % code)
                elif isinstance(value, str):
                    fh.write("b%s v%x\n" % (value, code))
                else:
                    fh.write("b%s v%x\n" % (format(value, 'b'), code))


######################################################################

parser = argparse.ArgumentParser(
    allow_abbrev=False,
    formatter_class=argparse.RawDescriptionHelpFormatter,
    description="""Create Gantt chart of Verilator multi-threaded execution

Verilator_gantt creates a visual representation to help analyze Verilator
#xmultithreaded simulation performance, by showing when each macro-task
#xstarts and ends, and showing when each thread is busy or idle.

For documentation see
https://verilator.org/guide/latest/exe_verilator_gantt.html""",
    epilog="""Copyright 2018-2025 by Wilson Snyder. This program is free software; you
can redistribute it and/or modify it under the terms of either the GNU
Lesser General Public License Version 3 or the Perl Artistic License
Version 2.0.

SPDX-License-Identifier: LGPL-3.0-only OR Artistic-2.0""")

parser.add_argument('--debug', action='store_true', help='enable debug')
parser.add_argument('--no-vcd', help='disable creating vcd', action='store_true')
parser.add_argument('--vcd', help='filename for vcd output', default='profile_exec.vcd')
parser.add_argument('filename',
                    help='input profile_exec.dat filename to process',
                    default='profile_exec.dat')

Args = parser.parse_args()

read_data(Args.filename)
report()
if not Args.no_vcd:
    write_vcd(Args.vcd)

######################################################################
# Local Variables:
# compile-command: "./verilator_gantt ../test_regress/t/t_gantt_io.dat"
# End:
