Technical Evaluation: How does memory consumption of VeriGym’s datastructures for explicit models compare to that of other tools?

We compare the memory consumption of VeriGym’s TransitionFunction and FrameworkExplicitEnv with the memory consumption of stormpy.SparseTransitionMatrix and stormpy.storage.SparseMdp. Further, we compare the sparse transition function representations from VeriGym and stormpy with non-sparse representation using numpy-arrays.

The goal is to 1) see whether/how much memory overhead VeriGym introduces over stormpy, and 2) compare the extend to which our sparse representation reduces memory consumption as opposed to non-sparse representations.

We use four PRISM MDP benchmarks from the Quantitative Verification Benchmark Set (qcomp) at different sizes. Specifically, we consider two grid-worlds (Pacman and Resource Gathering), and two internet protocols (Consensus and Zeroconf) to cover models with different structures. Models have states spaces \(S\) with \(400 \leq |S| \leq 1.000.000\).

Imports and Helpers

import os
# checks if env var is set, defaults to True
QUICK = os.environ.get("QUICK", "1") != "0" 
# uncomment line below for full run
# QUICK = False 
import json
import os
import subprocess
import sys

import matplotlib.pyplot as plt
import numpy as np
import scipy

from verigym.frameworks.stormpy.stormpy_utils import load_stormpy_model
from verigym.environments.frameworkexplicitenv import FrameworkExplicitEnv

import workflow_utils.plot_style as plot_style
from workflow_utils.utils import OUTPATH_TECH_EVAL

FIGPATH = f"{OUTPATH_TECH_EVAL}/memory_eval"
RESULTSPATH = f"{OUTPATH_TECH_EVAL}/memory_eval/logs"
def recursive_sizeof(obj, seen=None):
    if seen is None:
        seen = set()
    obj_id = id(obj)

    if obj_id in seen:
        return 0

    seen.add(obj_id)
    size = sys.getsizeof(obj)

    if isinstance(obj, dict):
        size += sum(
            recursive_sizeof(key, seen) + 
            recursive_sizeof(value, seen)
            for key, value in obj.items()
        )

    elif isinstance(obj, (list, tuple, set, frozenset)):
        size += sum(recursive_sizeof(item, seen) for item in obj)
    else:
        # Attributes stored in __dict__
        if hasattr(obj, "__dict__"):
            size += recursive_sizeof(obj.__dict__, seen)
        # Attributes stored using __slots__
        slots = getattr(type(obj), "__slots__", ())
        if isinstance(slots, str):
            slots = (slots,)

        for slot in slots:
            if slot in ("__dict__", "__weakref__"):
                continue
            try:
                value = getattr(obj, slot)
            except AttributeError:
                continue

            size += recursive_sizeof(value, seen)
    return size
def compute_transition_matrix_size(mdp):
    # Stormpy transition matrix is CSA sparse matrix, can compare like so
    transitions_bit = mdp.nr_transitions * 64
    choice_bits = mdp.nr_choices * 64 * 2
    state_bits = mdp.nr_states * 64

    bits = transitions_bit + choice_bits + state_bits
    return bits

def transition_function_to_np(n_states, n_actions, t_dict):
    non_sparse = np.zeros((n_states, n_actions, n_states), dtype=np.float64)

    for s in t_dict.keys():
        for a in t_dict[s].keys():
            for s_prime in t_dict[s][a].keys():
                non_sparse[s, a, s_prime] = t_dict[s][a][s_prime]

    return non_sparse

def get_csr(tr_array):
    return scipy.sparse.csr_array(tr_array.reshape(-1, tr_array.shape[-1]))

def compute_non_sparse_size(transition_matrix):
    """
    compute the size of a non sparse transition matrix
    assuming data type float64 i.e. 64b per float
    """
    nr_rows = transition_matrix.nr_rows
    nr_columns = transition_matrix.nr_columns
    return 64 * nr_rows * nr_columns

def compute_csr_size(transition_matrix):
    """
    approximate the size of a sparse SciPy CSR matrix
    assuming data type float64 for probabilities and int32 for indices and pointers, these are default values
    """
    n_rows = transition_matrix.nr_rows
    n_entries = transition_matrix.nr_entries
    return 2 * 32 * n_entries + 64 * n_rows
in_folder = "workflow_utils/prism_files/"
scripts_folder = "tech_eval_utils/"

benchmarks = [
    {
        "name": "Consensus.4",
        "file": os.path.abspath(in_folder + "consensus.4.v1.prism"),
        "constants": ["K=2", "K=4"]
    }, {
        "name": "Pacman",
        "file": in_folder + "pacman.v2.nm",
        "constants": ["MAXSTEPS=5"],
    }, {
        "name": "Resource Gathering",
        "file": in_folder + "resource-gathering.v2.pm",
        "constants": ["B=200,GOLD_TO_COLLECT=15,GEM_TO_COLLECT=15",
                      "B=400,GOLD_TO_COLLECT=30,GEM_TO_COLLECT=30",
                      "B=1300,GOLD_TO_COLLECT=100,GEM_TO_COLLECT=100"],
    }, {
        "name": "Zeroconf",
        "file": in_folder + "zeroconf.v1.prism",
        "constants": ["N=20,K=2,reset=true", "N=20,K=4,reset=true",
                      "N=20,K=2,reset=false", "N=20,K=4,reset=false",
                      "N=20,K=6,reset=false"
                      ]
    }, 
]

if QUICK:
    # If QUICK, reduce number of benchmarks
    for b in benchmarks:
        b['constants'] = b['constants'][:1]

Evaluation

MAX_NR_STATES = 4e4 if not QUICK else 1e2

transition_function_sizes = {}
env_or_mdp_sizes = {}

for benchmark in benchmarks:
    print(benchmark["name"])
    transition_function_sizes[benchmark["name"]] = {}
    env_or_mdp_sizes[benchmark["name"]] = {}
    for i, constants in enumerate(benchmark["constants"]):
        print(constants)
        mdp = load_stormpy_model(
            benchmark["file"], constants=constants
        )            
        env = FrameworkExplicitEnv.from_stormpy(mdp)

        res = subprocess.run(
            [
                sys.executable, f"{scripts_folder}/memory_eval_runner.py",
                "stormpy",
                benchmark["file"],
                "--constants", constants,
            ],
            capture_output=True,
            text=True,
            check=True
        )
        mdp_out = json.loads(res.stdout)

        res = subprocess.run(
            [
                sys.executable, f"{scripts_folder}/memory_eval_runner.py",
                "env",
                benchmark["file"],
                "--constants", constants,
            ],
            capture_output=True,
            text=True,
            check=True
        )
        env_out = json.loads(res.stdout)
        transition_function_sizes[benchmark["name"]][constants] = {
            "stormpy": compute_transition_matrix_size(mdp),
            "verigym": recursive_sizeof(env.transition_function),
            "n_states": mdp.nr_states,
            "n_transitions": mdp.nr_transitions,
        }
        transition_function_sizes[benchmark["name"]][constants]["flat"] = compute_non_sparse_size(mdp.transition_matrix)
        transition_function_sizes[benchmark["name"]][constants]["sparse"] = compute_csr_size(mdp.transition_matrix)
        env_or_mdp_sizes[benchmark["name"]][constants] = {
            "verigym,sizeoff": recursive_sizeof(env),
            "verigym,process": env_out["change"],
            "verigym,process,w.build": env_out["change_mdp"],
            "stormpy,process": mdp_out["change"],
            "n_states": mdp.nr_states,
            "n_transitions": mdp.nr_transitions,
        }
    print()

# Save the results
RESULTS_FILE = f"{RESULTSPATH}/memory_eval_results.json"
os.makedirs(RESULTSPATH, exist_ok=True)
with open(RESULTS_FILE, "w") as f:
    json.dump({"transition_function_sizes": transition_function_sizes, "env_or_mdp_sizes": env_or_mdp_sizes}, f, indent=2)
# Load the saved results (written by the experiment cell above)
with open(f"{RESULTSPATH}/memory_eval_results.json") as f:
    saved = json.load(f)
transition_function_sizes = saved["transition_function_sizes"]
env_or_mdp_sizes = saved["env_or_mdp_sizes"]

fig, axs = plt.subplots(figsize=(4,3))

# axs[0].set_title("Size of transition functions (bit)")
for i, (tb, tvariants) in enumerate(transition_function_sizes.items()):
    for j, (tc, data) in enumerate(tvariants.items()):
        axs.scatter(data["n_states"], data["stormpy"], 
                       label="stormpy" if i == 0 and j == 0 else "", 
                       color="blue")
        axs.scatter(data["n_states"], data["verigym"], 
                       label="verigym" if i == 0 and j == 0 else "", 
                       color="green" )
        if "flat" in data.keys():
            axs.scatter(data["n_states"], data["flat"], 
                        label="flat" if i == 0 and j == 0 else "", 
                        color="purple")
            axs.scatter(data["n_states"], data["sparse"],
                                       label="sparse" if i == 0 and j == 0 else "",
                                       color=plot_style.MAGENTA)
        

axs.set_yscale("log")
axs.set_xscale("log")
axs.set_xlabel("number of states")
axs.set_ylabel("transition function size (bit)")
axs.legend(frameon=True)

fig.savefig(f"{FIGPATH}/memory_eval_1a_transition_func.pdf", pad_inches=0, bbox_inches="tight")


# ------

fig, axs = plt.subplots(figsize=(4, 3))

# axs[1].set_title("Size of environment/MDP (bit)")
for i, (eb, evariants) in enumerate(env_or_mdp_sizes.items()):
    for j, (ec, data) in enumerate(evariants.items()):
        axs.scatter(data["n_states"], data["stormpy,process"], 
                       label="stormpy" if i == 0 and j == 0 else "", 
                       color="blue")
        axs.scatter(data["n_states"], data["verigym,process"], 
                       label="verigym (process)" if i == 0 and j == 0 else "", 
                       color="darkgreen")
        axs.scatter(data["n_states"], data["verigym,process,w.build"], 
                       label="verigym (process + MDP)" if i == 0 and j == 0 else "", 
                       color="lime")

axs.set_yscale("log") 
axs.set_xscale("log")
axs.legend(frameon=True)
axs.set_xlabel("number of states")
axs.set_ylabel("MDP size (bit)")

fig.savefig(f"{FIGPATH}/memory_eval_1b_size_environment.pdf", pad_inches=0, bbox_inches="tight")

plt.show()
fig, axs = plt.subplots(figsize=(4,3))

# axs.set_title("Size of transition function relative to stormpy")
for i, (tb, tvariants) in enumerate(transition_function_sizes.items()):
    for j, (tc, data) in enumerate(tvariants.items()):
        axs.scatter(data["n_states"], data["verigym"]/data["stormpy"], 
                       label="verigym" if i == 0 and j == 0 else "", 
                       color="green" )
        if "flat" in data.keys():
            axs.scatter(data["n_states"], data["flat"]/data["stormpy"], 
                        label="flat" if i == 0 and j == 0 else "", 
                        color="purple")
            axs.scatter(data["n_states"], data["sparse"]/data["stormpy"],
                           label="sparse" if i == 0 and j == 0 else "",
                           color=plot_style.MAGENTA)

axs.set_yscale("log")
axs.set_xscale("log")
axs.set_xlabel("number of states")
axs.set_ylabel("relative size increase transition function\n(verigym/stormpy)")
axs.grid(axis='y')
axs.legend(frameon=True)

fig.savefig(f"{FIGPATH}/memory_eval_2a_transition_func.pdf", pad_inches=0, bbox_inches="tight")


# ----


fig, axs = plt.subplots(figsize=(4,3))
# axs.set_title("Size of environment relative to stormpy process")
for i, (eb, evariants) in enumerate(env_or_mdp_sizes.items()):
    for j, (ec, data) in enumerate(evariants.items()):
        axs.scatter(data["n_states"], data["verigym,process"]/data["stormpy,process"], 
                       label="verigym (process)" if i == 0 and j == 0 else "", 
                       color="darkgreen")
        axs.scatter(data["n_states"], data["verigym,process,w.build"]/data["stormpy,process"], 
                       label="verigym (process + MDP)" if i == 0 and j == 0 else "", 
                       color="lime")

axs.set_xscale("log")
axs.set_xlabel("number of states")
axs.set_ylabel("environment relative size increase\n(verigym/stormpy)")

axs.legend()
fig.savefig(f"{FIGPATH}/memory_eval_2b_environment_size.pdf", pad_inches=0, bbox_inches="tight")

plt.show()
fig, axs = plt.subplots(figsize=(4,3))

# axs.set_title("Size of transition function relative to stormpy")
for i, (tb, tvariants) in enumerate(transition_function_sizes.items()):
    for j, (tc, data) in enumerate(tvariants.items()):
        axs.scatter(data["n_transitions"], data["verigym"]/data["stormpy"], 
                       label="verigym" if i == 0 and j == 0 else "", 
                       color="green" )
        if "flat" in data.keys():
            axs.scatter(data["n_transitions"], data["flat"]/data["stormpy"], 
                        label="flat" if i == 0 and j == 0 else "", 
                        color="purple")
            axs.scatter(data["n_transitions"], data["sparse"]/data["stormpy"],
                                       label="sparse" if i == 0 and j == 0 else "",
                                       color=plot_style.MAGENTA)

axs.set_yscale("log")
axs.set_xscale("log")
axs.set_xlabel("number of transitions")
axs.set_ylabel("relative size increase transition function\n(verigym/stormpy)")
axs.legend(frameon=True)

fig.savefig(f"{FIGPATH}/memory_eval_3b_transition_func.pdf", pad_inches=0, bbox_inches="tight")

# -----

fig, axs = plt.subplots(figsize=(4,3))

# axs.set_title("Size of environment relative to stormpy process")
for i, (eb, evariants) in enumerate(env_or_mdp_sizes.items()):
    for j, (ec, data) in enumerate(evariants.items()):
        axs.scatter(data["n_transitions"], data["verigym,process"]/data["stormpy,process"], 
                       label="verigym (process)" if i == 0 and j == 0 else "", 
                       color="darkgreen")
        axs.scatter(data["n_transitions"], data["verigym,process,w.build"]/data["stormpy,process"], 
                       label="verigym (process + MDP)" if i == 0 and j == 0 else "", 
                       color="lime")

axs.legend()
axs.set_xscale("log")
axs.set_xlabel("number of transitions")
axs.set_ylabel("relative environment size increase\n(verigym/stormpy)")

fig.savefig(f"{FIGPATH}/memory_eval_3b_environment_size.pdf", pad_inches=0, bbox_inches="tight")

plt.show()