[SYSTEMDS-3978] Add Algebra Rules to Scuro
This patch adds rules for the DARE-Algebra to Scuro that enable better node sharing, and ordering.
Assisted-by: AI
diff --git a/src/main/python/systemds/scuro/dataloader/json_loader.py b/src/main/python/systemds/scuro/dataloader/json_loader.py
index d280289..c836164 100644
--- a/src/main/python/systemds/scuro/dataloader/json_loader.py
+++ b/src/main/python/systemds/scuro/dataloader/json_loader.py
@@ -81,7 +81,7 @@
self.data.append(text)
self.metadata.append(
- self.modality_type.create_metadata(len(text), text) | record
+ {**self.modality_type.create_metadata(len(text), text), **record}
)
def get_stats(self, source_path: str):
diff --git a/src/main/python/systemds/scuro/drsearch/algebra.py b/src/main/python/systemds/scuro/drsearch/algebra.py
new file mode 100644
index 0000000..880e7af
--- /dev/null
+++ b/src/main/python/systemds/scuro/drsearch/algebra.py
@@ -0,0 +1,577 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+import hashlib
+import itertools
+import json
+from collections import Counter
+from dataclasses import dataclass
+from fractions import Fraction
+from functools import lru_cache
+from math import comb, factorial, prod
+from typing import NamedTuple, Optional
+
+import numpy as np
+
+from systemds.scuro.drsearch.operator_registry import Registry, load_builtin_operators
+from systemds.scuro.modality.type import ModalityType
+from systemds.scuro.representations.aggregated_representation import (
+ AggregatedRepresentation,
+)
+from systemds.scuro.representations.context import Context
+from systemds.scuro.representations.dimensionality_reduction import (
+ DimensionalityReduction,
+)
+from systemds.scuro.representations.fusion import Fusion
+from systemds.scuro.representations.representation import (
+ FitRequirement,
+ RepresentationStats,
+)
+from systemds.scuro.representations.zero_pad import ZeroPad
+
+ALL_LAWS = frozenset(
+ {
+ "comm",
+ "assoc",
+ "avg_flatten",
+ "concat_perm",
+ "agg_pushdown",
+ "implicit_agg",
+ "implicit_pad",
+ }
+)
+
+EXACT_LAWS = frozenset({"agg_pushdown", "implicit_agg", "implicit_pad"})
+
+
+@dataclass(frozen=True)
+class AlgebraConfig:
+ """Switches for the Beta-algebra rules; the defaults are the legacy search."""
+
+ laws: frozenset = frozenset()
+ feature_order_invariant: Optional[bool] = None
+ cse_law_aware: bool = False
+ ga_canonical_dedup: bool = False
+ exhaustive_dedup_permutations: bool = False
+ exhaustive_canonical: bool = False
+ typecheck: str = "off" # off | warn | strict
+ explicit_fusion_prep: bool = False
+ explicit_padding: str = "off" # off | pad | reject
+ typed_chaining: bool = False
+ share_fused_subtrees: bool = False
+
+ @classmethod
+ def full(cls) -> "AlgebraConfig":
+ return cls(
+ laws=ALL_LAWS,
+ feature_order_invariant=True,
+ cse_law_aware=True,
+ ga_canonical_dedup=True,
+ exhaustive_dedup_permutations=True,
+ exhaustive_canonical=True,
+ typecheck="warn",
+ explicit_fusion_prep=True,
+ explicit_padding="pad",
+ share_fused_subtrees=True,
+ )
+
+
+class OperatorSignature(NamedTuple):
+ kind: str # encode | context | aggregate | reduce | fuse | task
+ input_types: frozenset
+ output_type: Optional[ModalityType] # None: the input's type
+ arity: object # 1, or "n" for fusion
+ input_rank: str # any | seq | windows | vector
+ output_rank: str
+
+
+ANY_TYPE = frozenset(m for m in ModalityType if isinstance(m.value, int))
+
+
+def operator_signature(op_cls, parameters=None) -> OperatorSignature:
+ """`op_cls` None is a task node."""
+ if op_cls is None:
+ return OperatorSignature("task", ANY_TYPE, None, 1, "any", "any")
+ if issubclass(op_cls, AggregatedRepresentation):
+ return OperatorSignature("aggregate", ANY_TYPE, None, 1, "seq", "vector")
+ if issubclass(op_cls, ZeroPad):
+ return OperatorSignature("reduce", ANY_TYPE, None, 1, "vector", "vector")
+ if issubclass(op_cls, Fusion):
+ keep_axis = (parameters or {}).get("preserve_leading_axis")
+ seq_in = op_cls.preserves_leading_axis if keep_axis is None else keep_axis
+ ranks = ["seq" if seq else "vector" for seq in (seq_in, keep_axis)]
+ return OperatorSignature("fuse", ANY_TYPE, _output_type(op_cls), "n", *ranks)
+ if issubclass(op_cls, Context):
+ kind, lists, ranks = "context", Registry._context_operators, ("seq", "windows")
+ elif issubclass(op_cls, DimensionalityReduction):
+ kind, lists = "reduce", Registry._dimensionality_reduction_operators
+ ranks = ("vector", "vector")
+ else:
+ kind, lists, ranks = "encode", Registry._representations, ("any", "any")
+ load_builtin_operators(ANY_TYPE)
+ input_types = frozenset(m for m, ops in lists.items() if op_cls in ops)
+ return OperatorSignature(kind, input_types, _output_type(op_cls), 1, *ranks)
+
+
+@lru_cache(maxsize=None)
+def _needs_alignment(op_cls) -> bool:
+ return op_cls().needs_alignment
+
+
+@lru_cache(maxsize=None)
+def _output_type(op_cls):
+ return getattr(op_cls, "OUTPUT_MODALITY_TYPE", None) or getattr(
+ op_cls(), "output_modality_type", None
+ )
+
+
+class Violation(NamedTuple):
+ severity: str # error | implicit
+ kind: str # input_type | arity | hidden_aggregation | zero_padding
+ node_id: str
+ operator: str
+ detail: str
+
+
+def typecheck(dag, leaf_types, leaf_stats=None) -> list:
+ nodes = {node.node_id: node for node in dag.nodes}
+ types, violations = {}, []
+ stats = output_stats(nodes.__getitem__, leaf_stats)
+
+ def key(node):
+ return node.modality_id, node.representation_index
+
+ def flag(severity, kind, node, detail):
+ name = node.operation.__name__
+ violations.append(Violation(severity, kind, node.node_id, name, detail))
+
+ def check_fusion(node):
+ operands = [stats(i) for i in node.inputs]
+ if None in operands:
+ return
+ op = node.operation(params=node.parameters)
+ if not op.preserves_leading_axis:
+ ranks = [len(s.output_shape) for s in operands]
+ if max(ranks) > 1:
+ flag("implicit", "hidden_aggregation", node, f"operand ranks {ranks}")
+ if op.needs_alignment:
+ widths = [s.output_shape[-1] for s in op._fusion_input_stats(operands)]
+ if len(set(widths)) > 1:
+ flag("implicit", "zero_padding", node, f"operand widths {widths}")
+
+ def out_type(nid):
+ if nid in types:
+ return types[nid]
+ node = nodes[nid]
+ if not node.inputs:
+ types[nid] = leaf_types.get(key(node))
+ return types[nid]
+ in_types = [out_type(i) for i in node.inputs]
+ if node.operation is None:
+ types[nid] = in_types[0]
+ return types[nid]
+ sig = operator_signature(node.operation, node.parameters)
+ if (sig.arity == "n") != (len(node.inputs) > 1):
+ flag("error", "arity", node, f"{len(node.inputs)} input(s)")
+ for t in in_types:
+ if t is not None and t not in sig.input_types:
+ flag("error", "input_type", node, f"{t.name} input")
+ if sig.kind == "fuse" and leaf_stats is not None:
+ check_fusion(node)
+ types[nid] = sig.output_type or in_types[0]
+ return types[nid]
+
+ out_type(dag.root_node_id)
+ return violations
+
+
+def output_stats(get_node, leaf_stats):
+ from systemds.scuro.drsearch.representation_dag import (
+ pushdown_aggregation_for_node,
+ )
+
+ memo = {}
+
+ def stats(nid):
+ if nid not in memo:
+ node = get_node(nid)
+ memo[nid] = None
+ if not node.inputs:
+ key = node.modality_id, node.representation_index
+ memo[nid] = (leaf_stats or {}).get(key)
+ elif node.operation is not None:
+ inputs = [stats(i) for i in node.inputs]
+ if None not in inputs:
+ try:
+ out = node.operation(params=node.parameters).get_output_stats(
+ inputs if len(inputs) > 1 else inputs[0]
+ )
+ agg = pushdown_aggregation_for_node(node.parameters)
+ memo[nid] = agg.get_output_stats(out) if agg else out
+ except Exception:
+ pass
+ return memo[nid]
+
+ return stats
+
+
+def sample_stats(modality) -> Optional[RepresentationStats]:
+ data = getattr(modality, "data", None)
+ if data is None or len(data) == 0:
+ return None
+ try:
+ sample = np.asarray(data[0])
+ except (TypeError, ValueError):
+ return None
+ if sample.dtype == object:
+ return None
+ shape = tuple(sample.shape) if sample.ndim else (1,)
+ return RepresentationStats(len(data), shape, output_shape_is_known=True)
+
+
+def explicit_prep_on(algebra) -> bool:
+ return algebra.explicit_fusion_prep or algebra.explicit_padding == "pad"
+
+
+def explicit_fusion_operands(
+ builder, operation, parameters, inputs, leaf_stats, algebra
+) -> list:
+ if not issubclass(operation, Fusion):
+ return inputs
+ op = operation(params=parameters)
+ stats = output_stats(builder.get_node, leaf_stats)
+ if algebra.explicit_fusion_prep and not op.preserves_leading_axis:
+ agg = AggregatedRepresentation(target_dimensions=1, aggregate_leading=True)
+ inputs = [
+ (
+ builder.create_operation_node(
+ AggregatedRepresentation, [i], agg.get_current_parameters()
+ )
+ if stats(i) is not None and len(stats(i).output_shape) > 1
+ else i
+ )
+ for i in inputs
+ ]
+ shapes = [stats(i) for i in inputs]
+ if (
+ algebra.explicit_padding == "pad"
+ and op.needs_alignment
+ and None not in shapes
+ and all(len(s.output_shape) == 1 for s in shapes)
+ ):
+ width = max(s.output_shape[0] for s in shapes)
+ inputs = [
+ (
+ builder.create_operation_node(
+ ZeroPad,
+ [i],
+ ZeroPad(width, s.output_shape[0]).get_current_parameters(),
+ )
+ if s.output_shape[0] < width
+ else i
+ )
+ for i, s in zip(inputs, shapes)
+ ]
+ return inputs
+
+
+def count_explicit_prep(counter, dag) -> None:
+ """Counts the explicit operand preparation in a plan about to be evaluated."""
+ nodes = {n.node_id: n for n in dag.filter_connected_nodes(dag.nodes)}
+ padded = False
+ for node in nodes.values():
+ if not (node.operation and issubclass(node.operation, Fusion)):
+ continue
+ for operand in (nodes[i] for i in node.inputs):
+ if operand.operation is ZeroPad:
+ padded = True
+ ratio = operand.parameters["width"] / operand.parameters["input_width"]
+ counter["explicit_paddings"] += 1
+ counter["explicit_padding_ratio_sum"] += ratio
+ counter["explicit_padding_ratio_max"] = max(
+ counter["explicit_padding_ratio_max"], ratio
+ )
+ operand = nodes[operand.inputs[0]]
+ if operand.operation is AggregatedRepresentation:
+ counter["explicit_aggregations"] += 1
+ counter["explicit_prep_plans"] += 1
+ counter["explicit_padding_plans"] += padded
+
+
+def count_violations(counter, violations) -> list:
+ """Adds per-kind and per-operator counts; returns the errors."""
+ counter["typecheck_plans"] += 1
+ for v in violations:
+ counter[f"typecheck_{v.kind}"] += 1
+ counter[f"typecheck_{v.kind}:{v.operator}"] += 1
+ return [v for v in violations if v.severity == "error"]
+
+
+def order_invariant(config: AlgebraConfig, tasks) -> bool:
+ if config.feature_order_invariant is not None:
+ return config.feature_order_invariant
+ return bool(tasks) and all(
+ getattr(task.model, "feature_order_invariant", None) is True for task in tasks
+ )
+
+
+def canonical_signature(dag, laws, invariant, node_id=None) -> tuple:
+ # representation_dag imports this module.
+ from systemds.scuro.drsearch.representation_dag import _spec_safe
+
+ def view(nid):
+ node = dag.get_node_by_id(nid)
+ op = node.operation
+ params = {k: _spec_safe(v) for k, v in (node.parameters or {}).items()}
+ name = op.__name__ if op is not None else None
+ return (
+ name,
+ op,
+ params,
+ node.inputs,
+ node.modality_id,
+ node.representation_index,
+ )
+
+ return _signature(view, node_id or dag.root_node_id, laws, invariant)
+
+
+def canonical_signature_from_spec(spec: dict, laws, invariant) -> tuple:
+ nodes = {node["id"]: node for node in spec["nodes"]}
+ classes = _fusion_classes()
+
+ def view(nid):
+ n = nodes[nid]
+ return (
+ n["op"],
+ classes.get(n["op"]),
+ n["params"],
+ n["inputs"],
+ n["modality_id"],
+ n["representation_index"],
+ )
+
+ return _signature(view, spec["root"], laws, invariant)
+
+
+def canonical_id(dag, laws, invariant, node_id=None) -> str:
+ """`dag` is a RepresentationDag or a to_spec() dict."""
+ if isinstance(dag, dict):
+ sig = canonical_signature_from_spec(dag, laws, invariant)
+ else:
+ sig = canonical_signature(dag, laws, invariant, node_id)
+ blob = json.dumps(sig, sort_keys=True, separators=(",", ":"))
+ return hashlib.sha1(blob.encode()).hexdigest()[:16]
+
+
+def _fusion_classes() -> dict:
+ classes = {cls.__name__: cls for cls in Registry().get_fusion_operators()}
+ stack = [Fusion]
+ while stack:
+ cls = stack.pop()
+ classes.setdefault(cls.__name__, cls)
+ stack.extend(cls.__subclasses__())
+ return classes
+
+
+def _params_key(params) -> str:
+ return json.dumps(params, sort_keys=True, separators=(",", ":"))
+
+
+def _signature(view, root, laws, invariant) -> tuple:
+ # The aggregation Fusion._prepare_modalities applies to every operand of a
+ # fusion that does not preserve the leading axis.
+ implicit_agg = (
+ "op",
+ AggregatedRepresentation.__name__,
+ _params_key(
+ AggregatedRepresentation(
+ target_dimensions=1, aggregate_leading=True
+ ).get_current_parameters()
+ ),
+ )
+ memo = {}
+
+ def sig(nid, laws):
+ if (nid, laws) not in memo:
+ memo[nid, laws] = compute(nid, laws)
+ return memo[nid, laws]
+
+ def compute(nid, laws):
+ name, op, params, inputs, modality_id, representation_index = view(nid)
+ if not inputs:
+ return ("leaf", modality_id, representation_index)
+
+ children = [sig(i, laws & EXACT_LAWS if _fitted(op) else laws) for i in inputs]
+ params = dict(params)
+ pushed = (
+ params.pop("_pushdown_aggregation", None)
+ if "agg_pushdown" in laws
+ else None
+ )
+ head = ("op", name, _params_key(params))
+ fusion = isinstance(op, type) and issubclass(op, Fusion)
+ op_laws = op.algebraic_laws(params) & laws if fusion else frozenset()
+
+ if (
+ fusion
+ and "implicit_agg" in laws
+ and not params.get("preserve_leading_axis", op.preserves_leading_axis)
+ ):
+ children = [c[3][0] if c[:3] == implicit_agg else c for c in children]
+ if fusion and "implicit_pad" in laws and _needs_alignment(op):
+ children = [
+ c[3][0] if c[:2] == ("op", ZeroPad.__name__) else c for c in children
+ ]
+ if "assoc" in op_laws:
+ children = [g for c in children for g in (c[3] if c[:3] == head else (c,))]
+ if "avg_flatten" in op_laws:
+ weights = Counter()
+ for c in children:
+ for w, g in c[3] if c[:3] == head else (("1", c),):
+ weights[g] += Fraction(w) / len(children)
+ children = sorted(((str(w), g) for g, w in weights.items()), key=repr)
+ elif "comm" in op_laws or ("concat_perm" in op_laws and invariant):
+ children = sorted(children, key=repr)
+
+ out = head + (tuple(children),)
+ if pushed is not None:
+ out = ("op", AggregatedRepresentation.__name__, _params_key(pushed), (out,))
+ return out
+
+ return sig(root, frozenset(laws))
+
+
+def count_fusion_plans(n, fusion_ops, laws, invariant) -> dict:
+ heads = _fusion_heads(fusion_ops, laws)
+ f = len(heads)
+ reorders = sum(_reorders(op_laws, invariant) for *_, op_laws in heads)
+ ordered = factorial(2 * n - 2) // factorial(n - 1) * f ** (n - 1)
+ if n == 1:
+ canonical = 1
+ elif reorders == f and all("assoc" in op_laws for *_, op_laws in heads):
+ canonical = f * _flattened_trees(n, f)
+ elif n <= 4:
+ canonical = _brute_force_canonical(n, fusion_ops, laws, invariant)
+ else:
+ canonical = None
+ return {
+ "ordered": ordered,
+ "legacy_exhaustive": factorial(n) * ordered,
+ "commutative": _commutative_plans(n, heads, invariant),
+ "canonical": canonical,
+ }
+
+
+def count_search_space(
+ ks, fusion_ops, laws, invariant, min_modalities=2, max_modalities=None
+) -> dict:
+ """`ks[i]` is the number of representations of modality i."""
+ totals = dict.fromkeys(
+ ("ordered", "legacy_exhaustive", "commutative", "canonical"), 0
+ )
+ for r in range(min_modalities, (max_modalities or len(ks)) + 1):
+ leaves = sum(prod(c) for c in itertools.combinations(ks, r))
+ for key, plans in count_fusion_plans(r, fusion_ops, laws, invariant).items():
+ if plans is None or totals[key] is None:
+ totals[key] = None
+ else:
+ totals[key] += leaves * plans
+ return totals
+
+
+def _fitted(op) -> bool:
+ return getattr(op, "fit_requirement", None) not in (None, FitRequirement.NONE)
+
+
+def _commutative_plans(n, heads, invariant) -> int:
+ plans = {(1, True): 1, (1, False): 1}
+ for m in range(2, n + 1):
+ for free in (True, False):
+ plans[m, free] = 0
+ for *_, op, _, op_laws in heads:
+ below = free and not _fitted(op)
+ splits = sum(
+ comb(m, k) * plans[k, below] * plans[m - k, below]
+ for k in range(1, m)
+ )
+ plans[m, free] += splits // (
+ 2 if free and _reorders(op_laws, invariant) else 1
+ )
+ return plans[n, True]
+
+
+def _reorders(op_laws, invariant) -> bool:
+ return "comm" in op_laws or ("concat_perm" in op_laws and invariant)
+
+
+def _fusion_heads(fusion_ops, laws) -> list:
+ from systemds.scuro.drsearch.representation_dag import _spec_safe
+
+ heads = []
+ for op in fusion_ops:
+ params = {k: _spec_safe(v) for k, v in op().get_current_parameters().items()}
+ heads.append((op.__name__, op, params, op.algebraic_laws(params) & laws))
+ return heads
+
+
+def _flattened_trees(n, f) -> int:
+ tree, block, partitions = [0, 1], [0, 1], [1, 1]
+ for m in range(2, n + 1):
+ tree.append(
+ sum(comb(m - 1, j - 1) * block[j] * partitions[m - j] for j in range(1, m))
+ )
+ block.append((f - 1) * tree[m])
+ partitions.append(tree[m] + block[m])
+ return tree[n]
+
+
+def _ordered_trees(leaves):
+ if len(leaves) == 1:
+ yield leaves[0]
+ return
+ for split in range(1, len(leaves)):
+ for left in itertools.combinations(leaves, split):
+ right = tuple(i for i in leaves if i not in left)
+ for l_tree in _ordered_trees(left):
+ for r_tree in _ordered_trees(right):
+ yield (l_tree, r_tree)
+
+
+def _brute_force_canonical(n, fusion_ops, laws, invariant) -> int:
+ heads = _fusion_heads(fusion_ops, laws)
+ signatures = set()
+ for tree in _ordered_trees(tuple(range(n))):
+ for choice in itertools.product(heads, repeat=n - 1):
+ nodes, ops = {}, iter(choice)
+
+ def add(t):
+ if isinstance(t, int):
+ nodes[t] = (None, None, {}, (), t, 0)
+ return t
+ inputs = (add(t[0]), add(t[1]))
+ name, op, params, _ = next(ops)
+ nid = f"n{len(nodes)}"
+ nodes[nid] = (name, op, params, inputs, None, None)
+ return nid
+
+ root = add(tree)
+ signatures.add(_signature(nodes.__getitem__, root, laws, invariant))
+ return len(signatures)
diff --git a/src/main/python/systemds/scuro/drsearch/hyperparameter_tuner.py b/src/main/python/systemds/scuro/drsearch/hyperparameter_tuner.py
index bfb62bc..7805ac6 100644
--- a/src/main/python/systemds/scuro/drsearch/hyperparameter_tuner.py
+++ b/src/main/python/systemds/scuro/drsearch/hyperparameter_tuner.py
@@ -32,7 +32,10 @@
import random
from systemds.scuro.drsearch.representation_dag import (
RepresentationNode,
+ get_modality_by_id_and_instance_id,
)
+from systemds.scuro.representations.representation import infer_stats_from_data
+from systemds.scuro.utils.memory_utility import cpu_memory_budget_bytes
from systemds.scuro.modality.modality import Modality
from systemds.scuro.drsearch.split_aware_evaluator import (
SplitAwareDAGEvaluator,
@@ -151,6 +154,65 @@
return False
+def _is_leading_axis_concatenation(node: RepresentationNode) -> bool:
+ from systemds.scuro.representations.concatenation import Concatenation
+
+ return (
+ inspect.isclass(node.operation)
+ and issubclass(node.operation, Concatenation)
+ and bool((node.parameters or {}).get("preserve_leading_axis"))
+ )
+
+
+def _tied_window_params(dag) -> Dict[str, str]:
+ parent = {}
+
+ def find(node_id):
+ while parent[node_id] != node_id:
+ node_id = parent[node_id]
+ return node_id
+
+ def windows_under(node_id):
+ node = dag.get_node_by_id(node_id)
+ if _is_window_operation(node.operation):
+ return [node_id]
+ if _is_leading_axis_concatenation(node):
+ return [w for input_id in node.inputs for w in windows_under(input_id)]
+ return []
+
+ for node in dag.nodes:
+ if not _is_leading_axis_concatenation(node):
+ continue
+ windows = windows_under(node.node_id)
+ for window_id in windows:
+ parent.setdefault(window_id, window_id)
+ for window_id in windows[1:]:
+ parent[find(window_id)] = find(windows[0])
+
+ tied = {}
+ canonical = {}
+ for window_id in parent:
+ name = dag.get_node_by_id(window_id).operation.granularity_parameter
+ if name is None:
+ continue
+ key = (find(window_id), name)
+ canonical.setdefault(key, f"{window_id}-{name}")
+ if canonical[key] != f"{window_id}-{name}":
+ tied[f"{window_id}-{name}"] = canonical[key]
+ return tied
+
+
+def _expand_tied_params(
+ config: Dict[str, Any], tied_params: Dict[str, str]
+) -> Dict[str, Any]:
+ return {
+ **config,
+ **{
+ alias: config[name] for alias, name in tied_params.items() if name in config
+ },
+ }
+
+
def _materialize_node_params(
node: RepresentationNode, flat_params: Dict[str, Any]
) -> Dict[str, Any]:
@@ -581,6 +643,8 @@
for node_id in hyperparams
}
param_specs = self._build_param_specs(hyperparams, node_parameters)
+ tied_params = _tied_window_params(dag)
+ param_specs = [s for s in param_specs if s["name"] not in tied_params]
discrete_size = self._estimate_discrete_search_size(param_specs)
n_calls = min(discrete_size, max_evals) if max_evals else discrete_size
all_results = self._search_best_configs(
@@ -593,6 +657,7 @@
budget=n_calls,
initial_config=None,
rep_name=rep_name,
+ tied_params=tied_params,
)
all_results.append(baseline)
@@ -924,6 +989,7 @@
budget: int,
initial_config: Optional[Dict[str, Any]],
rep_name: str = "",
+ tied_params: Optional[Dict[str, str]] = None,
) -> List[Tuple[Dict[str, Any], Any]]:
import optuna
from optuna.trial import TrialState
@@ -1002,12 +1068,26 @@
trial_results: List[Tuple[Dict[str, Any], Any]] = []
def objective(trial: optuna.Trial) -> float:
- config = self._suggest_config_from_specs(trial, param_specs)
+ config = _expand_tied_params(
+ self._suggest_config_from_specs(trial, param_specs), tied_params or {}
+ )
key = self._config_key(config)
if key in seen:
trial.set_user_attr("duplicate", True)
raise optuna.TrialPruned()
+ estimate = self._estimate_trial_peak_bytes(
+ dag, config, modality_ids, task, modalities_override
+ )
+ budget_bytes = cpu_memory_budget_bytes()
+ if estimate is not None and estimate > budget_bytes:
+ self.logger.warning(
+ f"{rep_name}: skipping trial estimated at {estimate / 1024**3:.1f} GB "
+ f"(budget {budget_bytes / 1024**3:.1f} GB): {config}"
+ )
+ trial.set_user_attr("over_memory_budget", True)
+ raise optuna.TrialPruned()
+
params, scores = self.evaluate_dag_config(
dag,
config,
@@ -1072,6 +1152,47 @@
)
return cached_modalities
+ def _estimate_trial_peak_bytes(
+ self, dag, params, modality_ids, task, modalities_override=None
+ ) -> Optional[int]:
+ modalities = (
+ modalities_override
+ if modalities_override is not None
+ else self.get_modalities_by_id(modality_ids)
+ )
+ stats = {}
+
+ def visit(node_id):
+ if node_id in stats:
+ return stats[node_id]
+ node = dag.get_node_by_id(node_id)
+ if not node.inputs:
+ modality = get_modality_by_id_and_instance_id(
+ modalities, int(node.modality_id), node.representation_index
+ )
+ stats[node_id] = (
+ modality.get_stats()
+ if hasattr(modality, "get_stats")
+ else infer_stats_from_data(modality.data)
+ )
+ return stats[node_id]
+ inputs = [visit(input_id) for input_id in node.inputs]
+ if any(s is None for s in inputs):
+ stats[node_id] = None
+ return None
+ inputs = inputs[0] if len(inputs) == 1 else inputs
+ operation = node.operation(params=_apply_trial_params_to_node(node, params))
+ peaks.append(operation.estimate_peak_memory_bytes(inputs)["cpu_peak_bytes"])
+ stats[node_id] = operation.get_output_stats(inputs)
+ return stats[node_id]
+
+ peaks = []
+ output_stats = visit(dag.root_node_id)
+ if output_stats is None:
+ return None
+ peaks.append(task.estimate_peak_memory_bytes(output_stats)["cpu_peak_bytes"])
+ return int(max(peaks))
+
def _trial_node_cache(self, dag, params):
store = getattr(self, "_trial_cache", None)
if store is None:
diff --git a/src/main/python/systemds/scuro/drsearch/modality_shared_memory.py b/src/main/python/systemds/scuro/drsearch/modality_shared_memory.py
index b7472a3..1f78ff6 100644
--- a/src/main/python/systemds/scuro/drsearch/modality_shared_memory.py
+++ b/src/main/python/systemds/scuro/drsearch/modality_shared_memory.py
@@ -18,11 +18,28 @@
# under the License.
#
# -------------------------------------------------------------
+import os
+import shutil
from typing import Any, List, Tuple
import numpy as np
from multiprocessing import shared_memory
SHARED_MEMORY_MIN_BYTES = 1 * 1024 * 1024
+_SHM_DIR = "/dev/shm"
+
+
+def _shm_reserve_bytes() -> int:
+ return int(float(os.environ.get("SCURO_SHM_RESERVE_GB", "8")) * 1024**3)
+
+
+def _should_offload(data_nbytes: int) -> bool:
+ if data_nbytes < SHARED_MEMORY_MIN_BYTES:
+ return False
+ try:
+ free = shutil.disk_usage(_SHM_DIR).free
+ except OSError:
+ return True
+ return data_nbytes + _shm_reserve_bytes() <= free
def unlink_shm(name: str) -> None:
@@ -324,7 +341,7 @@
def add_shared_memory_candidate(data: Any, resident_bytes: int = 0) -> bool:
if _is_shared_memory_candidate(data):
data_nbytes = _estimate_ndarray_list_nbytes(data)
- if data_nbytes >= SHARED_MEMORY_MIN_BYTES:
+ if _should_offload(data_nbytes):
dtype = data[0].dtype
offsets = []
total_elems = 0
@@ -351,7 +368,7 @@
elif _is_shared_ndarray_candidate(data):
arr = data
data_nbytes = int(arr.nbytes)
- if data_nbytes >= SHARED_MEMORY_MIN_BYTES:
+ if _should_offload(data_nbytes):
shm = shared_memory.SharedMemory(create=True, size=data_nbytes)
shm_arr = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
np.copyto(shm_arr, arr, casting="no")
@@ -371,7 +388,7 @@
leaves.append(arr)
group_bounds.append((lo, len(leaves)))
data_nbytes = _estimate_ndarray_list_nbytes(leaves)
- if data_nbytes >= SHARED_MEMORY_MIN_BYTES:
+ if _should_offload(data_nbytes):
dtype = leaves[0].dtype
offsets = []
total_elems = 0
@@ -400,7 +417,7 @@
elif _is_string_list_shared_memory_candidate(data):
encoded = [s.encode("utf-8") for s in data]
data_nbytes = int(sum(len(b) for b in encoded))
- if data_nbytes >= SHARED_MEMORY_MIN_BYTES:
+ if _should_offload(data_nbytes):
shm = shared_memory.SharedMemory(create=True, size=data_nbytes)
mv = shm.buf
cursor = 0
diff --git a/src/main/python/systemds/scuro/drsearch/multimodal_ga_optimizer.py b/src/main/python/systemds/scuro/drsearch/multimodal_ga_optimizer.py
index 79a04ca..0c18233 100644
--- a/src/main/python/systemds/scuro/drsearch/multimodal_ga_optimizer.py
+++ b/src/main/python/systemds/scuro/drsearch/multimodal_ga_optimizer.py
@@ -22,6 +22,7 @@
from __future__ import annotations
import copy
+from collections import Counter
import gc
import os
import pickle
@@ -42,6 +43,18 @@
unlink_shm,
)
+from systemds.scuro.drsearch.algebra import (
+ AlgebraConfig,
+ canonical_id,
+ canonical_signature,
+ count_explicit_prep,
+ count_violations,
+ explicit_fusion_operands,
+ explicit_prep_on,
+ order_invariant,
+ sample_stats,
+ typecheck,
+)
from systemds.scuro.drsearch.operator_registry import Registry
from systemds.scuro.drsearch.representation_dag import (
RepresentationDAGBuilder,
@@ -53,6 +66,7 @@
SplitAwareDAGEvaluator,
dag_requires_fitting,
)
+from systemds.scuro.drsearch.trial_result_cache import TrialNodeCache, TrialResultCache
from systemds.scuro.drsearch.worker_pool import (
PersistentWorkerPool,
create_mp_context,
@@ -62,7 +76,7 @@
AggregatedRepresentation,
)
from systemds.scuro.representations.fusion import FusionInputTooLargeError
-from systemds.scuro.representations.representation import RepresentationStats
+from systemds.scuro.utils.memory_utility import gpu_memory_info
from systemds.scuro.utils.schema_helpers import get_shape
Tree = Union[int, Tuple["Tree", "Tree"]]
@@ -175,6 +189,7 @@
_TIMING_OBJECTIVES = {"runtime", "task_time", "representation_time"}
+_MAX_ERROR_SAMPLES = 5
ObjectiveSpec = Tuple[str, str] # (name, "max" | "min")
@@ -187,6 +202,14 @@
return val_score[name]
+def _task_uses_gpu(task: Task) -> bool:
+ uses_gpu = getattr(task.model, "uses_gpu", None)
+ if uses_gpu is not None:
+ return bool(uses_gpu)
+ device = getattr(task.model, "device", None)
+ return str(getattr(device, "type", device) or "").startswith("cuda")
+
+
def _failure_fitness(objective_specs: List[ObjectiveSpec]) -> Tuple[float, ...]:
return tuple(
float("-inf") if direction == "max" else float("inf")
@@ -205,15 +228,45 @@
pass
+_SUBTREE_CACHE: Optional[TrialResultCache] = None
+
+
+def _subtree_cache() -> TrialResultCache:
+ global _SUBTREE_CACHE
+ if _SUBTREE_CACHE is None:
+ gb = float(os.environ.get("SCURO_GA_SUBTREE_CACHE_GB", "2"))
+ _SUBTREE_CACHE = TrialResultCache(int(gb * 1024**3))
+ return _SUBTREE_CACHE
+
+
+def _subtree_node_cache(dag: RepresentationDag, task: Task) -> TrialNodeCache:
+ signatures = {
+ node.node_id: (
+ task.model.name,
+ canonical_signature(dag, frozenset(), False, node.node_id),
+ )
+ for node in dag.nodes
+ if node.inputs
+ }
+ return TrialNodeCache(_subtree_cache(), signatures)
+
+
def _evaluate_genome_body(
dag: RepresentationDag,
task: Task,
modalities: List[Any],
objective_specs: List[ObjectiveSpec],
+ share_subtrees: bool = False,
) -> Tuple[Optional[Tuple[float, ...]], Optional[Dict[str, Any]]]:
start = time.time()
if dag_requires_fitting(dag):
- evaluation = SplitAwareDAGEvaluator(dag, modalities, task).evaluate()
+ node_cache = _subtree_node_cache(dag, task) if share_subtrees else None
+ if node_cache is not None:
+ store = node_cache.store
+ before = store.hits, store.misses, store.saved_seconds
+ evaluation = SplitAwareDAGEvaluator(
+ dag, modalities, task, node_cache=node_cache, share_fitted=share_subtrees
+ ).evaluate()
scores = evaluation.scores
total = evaluation.representation_time + evaluation.task_time
val_score = scores[1].average_scores
@@ -238,6 +291,13 @@
"task_timing": getattr(task, "last_run_timing", {}),
**timing,
}
+ if node_cache is not None:
+ payload["subtree_cache"] = {
+ "hits": store.hits - before[0],
+ "misses": store.misses - before[1],
+ "saved_seconds": store.saved_seconds - before[2],
+ "bytes": store.bytes,
+ }
return fitness, payload
fused = dag.execute(modalities, task, enable_cache=False)
@@ -281,11 +341,11 @@
def _dispatch_genome_evaluation(payload, _gpu_id):
- dag, task, modalities, objective_specs = payload
+ dag, task, modalities, objective_specs, *share = payload
try:
try:
fitness, result = _evaluate_genome_body(
- dag, task, modalities, objective_specs
+ dag, task, modalities, objective_specs, *share
)
if fitness is None:
fitness = _failure_fitness(objective_specs)
@@ -310,6 +370,9 @@
class MultimodalDeapOptimizer:
+ # For instances made without __init__, as regret_analysis/exhaustive_enumerator.py does.
+ algebra = AlgebraConfig()
+
def __init__(
self,
modalities: List[Any],
@@ -336,6 +399,7 @@
allow_repeated_modalities: bool = False,
threads_per_worker: Optional[int] = None,
max_feature_dim: Optional[int] = DEFAULT_MAX_FUSED_FEATURE_DIM,
+ algebra: Optional[AlgebraConfig] = None,
):
self.modalities = modalities
self.tasks = tasks
@@ -343,6 +407,13 @@
self.allow_repeated_modalities = allow_repeated_modalities
self.max_feature_dim = None if max_feature_dim is None else int(max_feature_dim)
self.oversized_candidates: Dict[str, int] = {}
+ self.algebra = algebra or AlgebraConfig()
+ self.algebra_stats = Counter()
+ self._order_invariant = order_invariant(self.algebra, tasks)
+ self._canonical_signatures: Dict[Tuple, Tuple] = {}
+ self._seen_legacy_signatures: set = set()
+ # (task, canonical signature) -> [first evaluation runtime, evaluations avoided]
+ self._canonical_classes: Dict[Tuple, List] = {}
self.min_modalities = max(1, min_modalities)
requested_max = max_modalities or len(modalities)
@@ -399,6 +470,7 @@
self.optimization_results: Dict[str, List[FusionSearchResult]] = {}
self.evaluation_errors: Dict[str, int] = {}
+ self.evaluation_error_samples: Dict[str, List[str]] = {}
self.hall_of_fame_size = max(1, hall_of_fame_size)
self.hall_of_fame: Dict[str, List[FusionSearchResult]] = {}
@@ -659,11 +731,18 @@
shared_modality._data = wrapped
shm_names.append(shm_name)
shared_modalities.append(shared_modality)
+ # Workers are spawned with CUDA_VISIBLE_DEVICES="" unless they get
+ # a GPU lane, so without these a CUDA task model cannot run at all.
worker_pool = PersistentWorkerPool(
self.max_workers,
_WORKER_DISPATCH,
ctx=create_mp_context(),
threads_per_worker=self.threads_per_worker,
+ gpu_devices=[info["index"] for info in gpu_memory_info()],
+ gpu_slots_per_device=int(
+ os.environ.get("SCURO_GPU_SLOTS_PER_DEVICE", "1")
+ ),
+ gpu_demand_fraction=1.0,
)
except Exception:
for shm_name in shm_names:
@@ -684,6 +763,17 @@
unlink_shm(shm_name)
self._parallel_shm_names = []
+ def _idle_lane(self, uses_gpu: bool):
+ pool = self._worker_pool
+ if uses_gpu:
+ for gpu_id in pool.gpu_worker_devices:
+ if pool.has_idle_worker_for(gpu_id):
+ return gpu_id
+ return None if pool.has_idle_cpu_worker else False
+ if pool.has_idle_worker_for(None, allow_gpu_worker_for_cpu=True):
+ return None
+ return False
+
def _evaluate_individuals_parallel(
self, individuals: List[Any], task: Task
) -> None:
@@ -711,20 +801,25 @@
ind.fitness.values = self._reject_oversized(task_name, genome, reason)
continue
dag = self._genome_to_dag(genome)
+ if explicit_prep_on(self.algebra):
+ count_explicit_prep(self.algebra_stats, dag)
pending_followers[sig] = []
pending_work.append((ind, dag, sig))
+ uses_gpu = _task_uses_gpu(task)
try:
while pending_work or jobs:
- while (
- pending_work
- and self._worker_pool.has_idle_worker
- and len(jobs) < self.batch_size
- ):
+ while pending_work and len(jobs) < self.batch_size:
+ lane = self._idle_lane(uses_gpu)
+ if lane is False:
+ break
ind, dag, sig = pending_work.pop(0)
job_id = self._worker_pool.submit(
"genome",
- (dag, task, self._parallel_modalities, self.objective_specs),
+ (dag, task, self._parallel_modalities, self.objective_specs)
+ + self._share_args(),
+ gpu_id=lane,
+ allow_gpu_worker_for_cpu=not uses_gpu,
)
jobs[job_id] = (ind, dag, sig)
@@ -771,10 +866,22 @@
self.evaluation_errors[task_name] = (
self.evaluation_errors.get(task_name, 0) + 1
)
- if self.debug and error is not None:
+ samples = self.evaluation_error_samples.setdefault(task_name, [])
+ if error is not None and len(samples) < _MAX_ERROR_SAMPLES:
+ spec = dag.to_spec() if dag is not None else None
+ samples.append(f"{spec}\n{error}")
+ if error is not None and (self.debug or len(samples) == 1):
print(f"[GA] genome evaluation failed for task={task_name}:\n{error}")
return
+ cache_stats = payload.get("subtree_cache")
+ if cache_stats:
+ for key in ("hits", "misses", "saved_seconds"):
+ self.algebra_stats[f"subtree_cache_{key}"] += cache_stats[key]
+ self.algebra_stats["subtree_cache_max_bytes"] = max(
+ self.algebra_stats["subtree_cache_max_bytes"], cache_stats["bytes"]
+ )
+
result = FusionSearchResult(
dag=dag,
train_score=payload["train_score"],
@@ -798,6 +905,13 @@
)
self.optimization_results.setdefault(task_name, []).append(result)
self._update_hall_of_fame(task_name, result)
+ if self.algebra.ga_canonical_dedup:
+ cls = self._canonical_classes.setdefault(
+ (task_name, self._canonical_signature(dag)), [None, 0]
+ )
+ if cls[0] is None:
+ cls[0] = result.runtime
+ self.algebra_stats["ga_seconds_avoided"] += cls[0] * cls[1]
self._eval_counter += 1
def _dominates(self, a: Tuple[float, ...], b: Tuple[float, ...]) -> bool:
@@ -962,18 +1076,35 @@
builder.create_leaf_node(mod_id, repr_idx)
for mod_id, repr_idx in genome.leaves
]
+ leaf_stats = (
+ {
+ leaf: self._leaf_stats(self._current_task_name, leaf)
+ for leaf in genome.leaves
+ }
+ if self is not None
+ and explicit_prep_on(self.algebra)
+ and self._current_task_name is not None
+ else None
+ )
def build(subtree: Tree, path: str) -> str:
if isinstance(subtree, int):
return leaf_ids[subtree]
left, right = subtree
- left_id = build(left, path + "L")
- right_id = build(right, path + "R")
+ inputs = [build(left, path + "L"), build(right, path + "R")]
op_cls = genome.fusion_ops[path]
op = op_cls()
- return builder.create_operation_node(
- op.__class__, [left_id, right_id], op.get_current_parameters()
- )
+ params = op.get_current_parameters()
+ if leaf_stats is not None:
+ inputs = explicit_fusion_operands(
+ builder,
+ op.__class__,
+ params,
+ inputs,
+ leaf_stats,
+ self.algebra,
+ )
+ return builder.create_operation_node(op.__class__, inputs, params)
return builder.build(build(genome.tree, ""))
@@ -981,11 +1112,41 @@
def norm(t: Tree):
return t if isinstance(t, int) else (norm(t[0]), norm(t[1]))
- return (
+ legacy = (
tuple(g.leaves),
norm(g.tree),
tuple(sorted((p, c.__name__) for p, c in g.fusion_ops.items())),
)
+ if not self.algebra.ga_canonical_dedup:
+ return legacy
+ canon = self._canonical_signatures.get(legacy)
+ if canon is None:
+ canon = self._canonical_signature(self._genome_to_dag(g))
+ self._canonical_signatures[legacy] = canon
+ self._count_signature_request(legacy, canon)
+ return canon
+
+ def _canonical_signature(self, dag: RepresentationDag) -> Tuple:
+ return (
+ "canon",
+ canonical_id(dag, self.algebra.laws, self._order_invariant),
+ )
+
+ def _count_signature_request(self, legacy: Tuple, canon: Tuple) -> None:
+ task_name = self._current_task_name
+ self.algebra_stats["ga_signature_requests"] += 1
+ if (task_name, legacy) in self._seen_legacy_signatures:
+ return
+ self._seen_legacy_signatures.add((task_name, legacy))
+ self.algebra_stats["ga_unique_legacy"] += 1
+ cls = self._canonical_classes.get((task_name, canon))
+ if cls is None:
+ self._canonical_classes[(task_name, canon)] = [None, 0]
+ self.algebra_stats["ga_unique_canonical"] += 1
+ return
+ cls[1] += 1
+ self.algebra_stats["ga_evaluations_avoided"] += 1
+ self.algebra_stats["ga_seconds_avoided"] += cls[0] or 0.0
def _leaf_stats(self, task_name: str, leaf: Tuple[Any, int]):
"""Shape of one candidate leaf representation, or None if unknown."""
@@ -996,18 +1157,7 @@
]
except (KeyError, IndexError, TypeError):
return None
-
- data = getattr(modality, "data", None)
- if data is None or len(data) == 0:
- return None
- try:
- sample = np.asarray(data[0])
- except (TypeError, ValueError):
- return None
- if sample.dtype == object:
- return None
- shape = tuple(sample.shape) if sample.ndim else (1,)
- return RepresentationStats(len(data), shape, output_shape_is_known=True)
+ return sample_stats(modality)
def _predicted_feature_dim(
self, task_name: str, genome: DagGenome
@@ -1071,7 +1221,24 @@
except Exception:
return None
+ def _type_error(self, task_name: str, genome: DagGenome) -> Optional[str]:
+ reps = self.k_best_representations[task_name]
+ types = {
+ (mid, idx): getattr(reps[mid][idx], "modality_type", None)
+ for mid, idx in genome.leaves
+ }
+ stats = {leaf: self._leaf_stats(task_name, leaf) for leaf in genome.leaves}
+ violations = typecheck(self._genome_to_dag(genome), types, stats)
+ errors = count_violations(self.algebra_stats, violations)
+ if errors and self.algebra.typecheck == "strict":
+ return f"type_error: {errors}"
+ return None
+
def _oversized_reason(self, task_name: str, genome: DagGenome) -> Optional[str]:
+ if self.algebra.typecheck != "off" and task_name is not None:
+ reason = self._type_error(task_name, genome)
+ if reason is not None:
+ return reason
reason = self._operator_input_violation(task_name, genome)
if reason is not None:
return f"{reason}; candidate rejected before execution"
@@ -1112,13 +1279,15 @@
return self._reject_oversized(task_name, genome, reason)
dag = self._genome_to_dag(genome)
+ if explicit_prep_on(self.algebra):
+ count_explicit_prep(self.algebra_stats, dag)
modalities = list(
chain.from_iterable(self.k_best_representations[task_name].values())
)
try:
fitness, payload = _evaluate_genome_body(
- dag, task, modalities, self.objective_specs
+ dag, task, modalities, self.objective_specs, *self._share_args()
)
error = None
if fitness is None:
@@ -1135,6 +1304,10 @@
cache[sig] = fitness
return fitness
+ def _share_args(self) -> Tuple:
+ # Legacy passes no extra argument, so 4-argument overrides keep working.
+ return (True,) if self.algebra.share_fused_subtrees else ()
+
def _crossover_genomes(
self, g1: DagGenome, g2: DagGenome
) -> Tuple[DagGenome, DagGenome]:
diff --git a/src/main/python/systemds/scuro/drsearch/multimodal_optimizer.py b/src/main/python/systemds/scuro/drsearch/multimodal_optimizer.py
index 54cddfd..22476bc 100644
--- a/src/main/python/systemds/scuro/drsearch/multimodal_optimizer.py
+++ b/src/main/python/systemds/scuro/drsearch/multimodal_optimizer.py
@@ -37,12 +37,23 @@
from systemds.scuro.representations.aggregated_representation import (
AggregatedRepresentation,
)
+from systemds.scuro.drsearch.algebra import (
+ AlgebraConfig,
+ canonical_id,
+ count_explicit_prep,
+ explicit_fusion_operands,
+ explicit_prep_on,
+ order_invariant,
+ sample_stats,
+)
from systemds.scuro.drsearch.operator_registry import Registry
from systemds.scuro.utils.schema_helpers import get_shape
from concurrent.futures import ProcessPoolExecutor, wait, FIRST_COMPLETED
+import math
import pickle
import copy
+from collections import Counter
import time
import traceback
from itertools import chain
@@ -128,6 +139,7 @@
checkpoint_every: int = None,
checkpoint_dir: Optional[str] = None,
resume: bool = False,
+ algebra: Optional[AlgebraConfig] = None,
):
self.modalities = modalities
self.tasks = tasks
@@ -135,6 +147,9 @@
self.debug = debug
self.min_modalities = max(2, min_modalities)
self.max_modalities = max_modalities or len(modalities)
+ self.algebra = algebra or AlgebraConfig()
+ self.algebra_stats = Counter()
+ self._order_invariant = order_invariant(self.algebra, tasks)
self.operator_registry = Registry()
self.fusion_operators = self.operator_registry.get_fusion_operators()
@@ -217,7 +232,7 @@
break
for dag in self._generate_fusion_dags(
- modality_subset, repr_combo
+ modality_subset, repr_combo, task.model.name
):
if max_combinations and evaluated_count >= max_combinations:
stop_generation = True
@@ -353,9 +368,20 @@
}
def _generate_fusion_dags(
- self, modality_subset: List[str], representation_combo: Dict[str, int]
+ self,
+ modality_subset: List[str],
+ representation_combo: Dict[str, int],
+ task_name: str = None,
) -> Generator[RepresentationDag, None, None]:
leaf_infos = [(m, representation_combo[m]) for m in modality_subset]
+ leaf_stats = (
+ {
+ (m, i): sample_stats(self.k_best_representations[task_name][m][i])
+ for m, i in leaf_infos
+ }
+ if explicit_prep_on(self.algebra) and task_name is not None
+ else None
+ )
def gen_trees(indices: List[int]):
if len(indices) == 1:
@@ -393,18 +419,35 @@
for fusion_op_class in self.fusion_operators:
new_builder = copy.deepcopy(right_builder)
fusion_op = fusion_op_class()
+ params = fusion_op.get_current_parameters()
+ inputs = [left_root, right_root]
+ if leaf_stats is not None:
+ inputs = explicit_fusion_operands(
+ new_builder,
+ fusion_op.__class__,
+ params,
+ inputs,
+ leaf_stats,
+ self.algebra,
+ )
fusion_id = new_builder.create_operation_node(
- fusion_op.__class__,
- [left_root, right_root],
- fusion_op.get_current_parameters(),
+ fusion_op.__class__, inputs, params
)
variants.append((new_builder, fusion_id))
return variants
n = len(leaf_infos)
+ dedup_permutations = self.algebra.exhaustive_dedup_permutations
+ # gen_trees already yields every leaf order, so permuting the leaves repeats each tree n! times.
+ leaf_orders = (
+ [leaf_infos]
+ if dedup_permutations
+ else itertools.permutations(leaf_infos, n)
+ )
+ seen_canonical = set()
- for permuted_leaf_infos in itertools.permutations(leaf_infos, n):
+ for permuted_leaf_infos in leaf_orders:
base_builder = RepresentationDAGBuilder()
leaf_id_map = {}
for idx, (modality_id, repr_idx) in enumerate(permuted_leaf_infos):
@@ -416,12 +459,28 @@
for tree in gen_trees(indices):
variants = build_variants(tree, base_builder, leaf_id_map)
for builder_variant, root_id in variants:
+ self.algebra_stats["exhaustive_generated"] += (
+ math.factorial(n) if dedup_permutations else 1
+ )
+ if dedup_permutations:
+ self.algebra_stats["exhaustive_after_permutation_dedup"] += 1
try:
- yield builder_variant.build(root_id)
+ dag = builder_variant.build(root_id)
except ValueError:
if self.debug:
print(f"[DEBUG] Skipping invalid DAG for root {root_id}")
continue
+ if self.algebra.exhaustive_canonical:
+ cid = canonical_id(
+ dag, self.algebra.laws, self._order_invariant
+ )
+ if cid in seen_canonical:
+ continue
+ seen_canonical.add(cid)
+ self.algebra_stats["exhaustive_after_canonical"] += 1
+ if leaf_stats is not None:
+ count_explicit_prep(self.algebra_stats, dag)
+ yield dag
def _evaluate_dag(self, dag: RepresentationDag, task: Task) -> "OptimizationResult":
start_time = time.time()
@@ -525,7 +584,9 @@
modality_subset, task.model.name
):
- for dag in self._generate_fusion_dags(modality_subset, repr_combo):
+ for dag in self._generate_fusion_dags(
+ modality_subset, repr_combo, task.model.name
+ ):
if max_combinations and evaluated_count >= max_combinations:
break
if skip_remaining > 0:
diff --git a/src/main/python/systemds/scuro/drsearch/node_executor.py b/src/main/python/systemds/scuro/drsearch/node_executor.py
index a498c27..4be1b66 100644
--- a/src/main/python/systemds/scuro/drsearch/node_executor.py
+++ b/src/main/python/systemds/scuro/drsearch/node_executor.py
@@ -163,6 +163,10 @@
return shm_name, resident_bytes, int(shm_bytes or 0), actual_stats
+def _measure_memory() -> bool:
+ return DEBUG or os.environ.get("SCURO_MEASURE_MEMORY", "0") == "1"
+
+
def _execute_node_worker(node, input_mods: List[Any], gpu_id: Optional[int]):
start_time = time.perf_counter()
if gpu_id is not None:
@@ -205,7 +209,7 @@
gpu_peak_bytes = -1
measurement = None
- if DEBUG:
+ if _measure_memory():
input_resident = sum(estimate_modality_bytes(m) for m in input_mods)
result, measurement = measure_memory_during(
lambda: _run_gpu_op(_run_node_op, gpu_id, cleanup_after=False),
@@ -271,7 +275,7 @@
gpu_peak_bytes = -1
measurement = None
- if DEBUG:
+ if _measure_memory():
result, measurement = measure_memory_during(
lambda: _run_gpu_op(_run_task, gpu_id),
input_resident_bytes=estimate_modality_bytes(modality),
@@ -480,6 +484,7 @@
self._cache_misses = 0
self._nodes_executed = 0
self._peak_cache_bytes = 0
+ self._leaf_cached_bytes = self.scheduler.memory_stats["cpu_cached"]
self._eval_counter = 0
self._nodes_executed = 0
self._search_start = (
@@ -741,6 +746,12 @@
finally:
if retained:
self._result_cache.release_shm_names(retained)
+ self._sync_cache_accounting()
+
+ def _sync_cache_accounting(self) -> None:
+ cache_bytes = self._result_cache.get_memory_total_memory_usage()
+ self._peak_cache_bytes = max(self._peak_cache_bytes, cache_bytes)
+ self.scheduler.set_cached_cpu_bytes(self._leaf_cached_bytes + cache_bytes)
def _handle_job_failure(self, unit: Union[_NodeUnit, _BatchUnit], jr):
reason = jr.error or "unknown worker failure"
@@ -860,8 +871,8 @@
operation_name,
actual_stats,
measurement=measurement,
+ estimated_stats=estimated_stats,
)
- before_bytes = self._result_cache.get_memory_total_memory_usage()
self._manage_result_cache(
node_id,
transformed_modality,
@@ -869,9 +880,7 @@
resident_bytes=resident_bytes,
shm_bytes=shm_bytes,
)
- after_bytes = self._result_cache.get_memory_total_memory_usage()
- self._peak_cache_bytes = max(self._peak_cache_bytes, after_bytes)
- self.scheduler.update_cpu_memory_in_use(after_bytes - before_bytes)
+ self._sync_cache_accounting()
self.scheduler.complete_node(node_id)
def _manage_result_cache(
@@ -906,9 +915,12 @@
operation_name: str,
actual_stats: Optional[RepresentationStats],
measurement: Optional[MemoryMeasurement] = None,
+ estimated_stats: Optional[RepresentationStats] = None,
):
if self.enable_checkpointing:
self._memory_usage_checkpoint.increment(node_id)
+ if estimated_stats is None:
+ estimated_stats = self.scheduler.node_stats.get(node_id)
if measurement is not None:
peak_bytes = measurement.footprint_bytes
@@ -962,6 +974,12 @@
"rss_delta_bytes": measurement.rss_delta_bytes if measurement else -1,
"num_instances": getattr(actual_stats, "num_instances", None),
"output_shape": getattr(actual_stats, "output_shape", None),
+ "estimated_output_shape": getattr(
+ estimated_stats, "output_shape", None
+ ),
+ "estimated_num_instances": getattr(
+ estimated_stats, "num_instances", None
+ ),
"dtype": str(getattr(actual_stats, "dtype", None)),
"container": str(getattr(actual_stats, "container", None)),
}
@@ -1100,6 +1118,13 @@
"cpu_fallback_nodes": len(
getattr(self.scheduler, "cpu_fallback_nodes", []) or []
),
+ "planned_task_nodes": len(getattr(self.scheduler, "roots", ()) or ()),
+ "completed_task_nodes": self._eval_counter,
+ "exclusive_memory_nodes": len(
+ getattr(self.scheduler, "exclusive_memory_nodes", {}) or {}
+ ),
+ "deadlock": bool(getattr(self.scheduler, "deadlock", False)),
+ "deadlock_reason": getattr(self.scheduler, "deadlock_reason", None),
}
)
return report
diff --git a/src/main/python/systemds/scuro/drsearch/node_scheduler.py b/src/main/python/systemds/scuro/drsearch/node_scheduler.py
index 78ef5b9..b69c50f 100644
--- a/src/main/python/systemds/scuro/drsearch/node_scheduler.py
+++ b/src/main/python/systemds/scuro/drsearch/node_scheduler.py
@@ -44,6 +44,10 @@
return os.environ.get("SCURO_EXCLUSIVE_OVER_BUDGET", "0") == "1"
+def _cache_drain_fraction() -> float:
+ return float(os.environ.get("SCURO_CACHE_DRAIN_FRACTION", "0.5"))
+
+
class MemoryAwareNodeScheduler:
def __init__(
@@ -141,16 +145,45 @@
def update_cpu_memory_in_use(self, delta_bytes: int):
self.memory_stats["cpu_cached"] += delta_bytes
+ def set_cached_cpu_bytes(self, cached_bytes: int) -> None:
+ self.memory_stats["cpu_cached"] = cached_bytes
+
+ def _cache_draining(self) -> bool:
+ budget = self.memory_budget["cpu"]
+ if _exclusive_over_budget_enabled() and self._total_cpu_in_use() >= budget:
+ return True
+ return self.memory_stats["cpu_cached"] >= _cache_drain_fraction() * budget
+
+ def _release_bytes(self, node_id: str) -> int:
+ release_bytes = 0
+ for parent_id in self.parents.get(node_id, set()):
+ if (
+ parent_id not in self.leaves
+ and self.remaining_children.get(parent_id, 0) == 1
+ ):
+ release_bytes += self.node_resources[parent_id][0]
+ return release_bytes
+
+ def _grows_cache(self, node_id: str) -> bool:
+ adds_bytes = (
+ self.node_resources[node_id][0] if self.children.get(node_id) else 0
+ )
+ return adds_bytes > self._release_bytes(node_id)
+
def get_runnable(self) -> List[RepresentationNode]:
runnable_nodes = self._get_runnable_nodes()
admitted_bytes = self._pending_admitted_cpu_bytes()
pending_gpu = {gpu_id: 0 for gpu_id in self.gpu_devices}
pending_slots = {gpu_id: 0 for gpu_id in self.gpu_devices}
+ draining = self._cache_draining()
+ hold_growing_nodes = False
for node in runnable_nodes:
if node in self._ready_set:
continue
+ if hold_growing_nodes and self._grows_cache(node):
+ continue
ok, gpu_id = self._check_memory_constraints(
node, admitted_bytes, pending_gpu, pending_slots
)
@@ -163,6 +196,13 @@
self._candidates.discard(node)
self.ready_nodes.append(node)
self._ready_set.add(node)
+ elif (
+ draining
+ and not self._grows_cache(node)
+ and self.node_resources[node][0]
+ > self.memory_budget["cpu"] - self._total_cpu_in_use() - admitted_bytes
+ ):
+ hold_growing_nodes = True
chunked_leaf_by_device = defaultdict(list)
for node in self.ready_nodes:
if isinstance(node, list):
@@ -189,28 +229,17 @@
def _get_runnable_nodes(self) -> List[str]:
runnable_nodes = list(self._candidates)
- # With exclusive admission a full budget no longer stops the run, so
- # the cache of intermediates must be drained deliberately: while the
- # CPU budget is exceeded, nodes whose completion does not grow the
- # cache (task roots, or last children releasing at least their own
- # output) run before nodes that would cache another output.
- draining_first = (
- _exclusive_over_budget_enabled()
- and self._total_cpu_in_use() >= self.memory_budget["cpu"]
- )
+ # Upward rank always puts a producer ahead of its consumers, so without
+ # a counterweight every large intermediate is materialized before the
+ # tasks that would release it. Once the cache holds a large share of
+ # the budget (or, with exclusive admission, the budget is exceeded),
+ # nodes whose completion does not grow the cache (task roots, or last
+ # children releasing at least their own output) run first.
+ draining_first = self._cache_draining()
def _score(node_id: str):
- release_bytes = 0
- for parent_id in self.parents.get(node_id, set()):
- if (
- parent_id not in self.leaves
- and self.remaining_children.get(parent_id, 0) == 1
- ):
- release_bytes += self.node_resources[parent_id][0]
- adds_bytes = (
- self.node_resources[node_id][0] if self.children.get(node_id) else 0
- )
- grows_cache = draining_first and adds_bytes > release_bytes
+ release_bytes = self._release_bytes(node_id)
+ grows_cache = draining_first and self._grows_cache(node_id)
return (
grows_cache,
-self.node_priorities.get(node_id, 0.0),
diff --git a/src/main/python/systemds/scuro/drsearch/representation_dag.py b/src/main/python/systemds/scuro/drsearch/representation_dag.py
index d84d092..efc4692 100644
--- a/src/main/python/systemds/scuro/drsearch/representation_dag.py
+++ b/src/main/python/systemds/scuro/drsearch/representation_dag.py
@@ -36,8 +36,20 @@
DimensionalityReduction,
)
from systemds.scuro.utils.identifier import get_op_id, get_node_id
+from systemds.scuro.drsearch.algebra import (
+ ALL_LAWS,
+ AlgebraConfig,
+ canonical_id,
+ count_explicit_prep,
+ count_violations,
+ explicit_fusion_operands,
+ explicit_prep_on,
+ order_invariant,
+ typecheck,
+)
+from systemds.scuro.representations.fusion import Fusion
import time
-from collections import OrderedDict, defaultdict, deque
+from collections import Counter, OrderedDict, defaultdict, deque
def pushdown_aggregation_for_node(
@@ -156,6 +168,9 @@
blob = json.dumps(self.to_spec(), sort_keys=True, separators=(",", ":"))
return hashlib.sha1(blob.encode()).hexdigest()[:16]
+ def canonical_id(self, laws=ALL_LAWS, invariant=False) -> str:
+ return canonical_id(self, laws, invariant)
+
def _topological_order(self) -> List[str]:
node_map = {node.node_id: node for node in self.nodes}
visited = []
@@ -670,7 +685,13 @@
class CSEAwareDAGBuilder:
- def __init__(self):
+ def __init__(self, algebra: Optional[AlgebraConfig] = None, tasks=None, leaves=()):
+ self.algebra = algebra or AlgebraConfig()
+ self.leaves = leaves
+ self.algebra_stats = Counter()
+ self._order_invariant = order_invariant(self.algebra, tasks)
+ self._seen_ordered = set()
+ self._seen_legacy = set()
self.global_nodes: List[RepresentationNode] = []
self.signature_to_node: Dict[Hashable, str] = {}
self.node_to_signature: Dict[str, Hashable] = {}
@@ -685,8 +706,28 @@
input_sigs = tuple(sorted(ip, key=repr)) if inputs else ()
op_cls = operation().name
params_items = tuple(sorted((parameters or {}).items()))
+ if self.algebra.cse_law_aware and not self._may_reorder(operation, parameters):
+ input_sigs = tuple(ip)
return ("op", op_cls, params_items, input_sigs)
+ def _may_reorder(self, operation: Any, parameters: Dict[str, Any]) -> bool:
+ if not issubclass(operation, Fusion):
+ return False
+ laws = operation.algebraic_laws(parameters or {})
+ return "comm" in laws or ("concat_perm" in laws and self._order_invariant)
+
+ def _count_law_merges(self, signature: Hashable, inputs: List[str]):
+ ip = tuple(self.node_to_signature[inp] for inp in inputs)
+ ordered = signature[:3] + (ip,)
+ legacy = signature[:3] + (tuple(sorted(ip, key=repr)),)
+ if signature in self.signature_to_node:
+ if ordered not in self._seen_ordered:
+ self.algebra_stats["cse_merges_by_law"] += 1
+ elif legacy in self._seen_legacy:
+ self.algebra_stats["cse_unsafe_merges_prevented"] += 1
+ self._seen_ordered.add(ordered)
+ self._seen_legacy.add(legacy)
+
def _compute_leaf_signature(
self, modality_id: str, representation_index: int = -1
) -> Hashable:
@@ -705,6 +746,8 @@
signature = self._compute_leaf_signature(modality_id, representation_index)
else:
signature = self._compute_node_signature(operation, inputs, parameters)
+ if self.algebra.cse_law_aware:
+ self._count_law_merges(signature, inputs)
try:
if signature in self.signature_to_node:
@@ -751,6 +794,15 @@
def create_operation_node(
self, operation: Any, inputs: List[str], parameters: Dict[str, Any] = None
):
+ if explicit_prep_on(self.algebra):
+ inputs = explicit_fusion_operands(
+ self,
+ operation,
+ parameters,
+ inputs,
+ self._leaf_stats(),
+ self.algebra,
+ )
return self._get_or_create_node(
operation=operation, inputs=inputs, parameters=parameters, is_leaf=False
)
@@ -769,11 +821,31 @@
self.dag_counter += 1
if not dag.validate():
raise ValueError("Invalid DAG construction")
+ if self.algebra.typecheck != "off":
+ self._typecheck(dag)
+ if explicit_prep_on(self.algebra):
+ count_explicit_prep(self.algebra_stats, dag)
if dag_id is None:
self._dag_by_root[root_node_id] = dag
return dag
+ def _leaf_stats(self) -> Dict[Hashable, Any]:
+ return {(m.modality_id, -1): getattr(m, "stats", None) for m in self.leaves}
+
+ def _typecheck(self, dag: RepresentationDag) -> None:
+ violations = typecheck(
+ dag,
+ {
+ (m.modality_id, -1): getattr(m, "modality_type", None)
+ for m in self.leaves
+ },
+ self._leaf_stats(),
+ )
+ errors = count_violations(self.algebra_stats, violations)
+ if errors and self.algebra.typecheck == "strict":
+ raise ValueError(f"Ill-typed plan: {errors}")
+
def get_node(self, node_id: str) -> Optional[RepresentationNode]:
for node in self.global_nodes:
if node.node_id == node_id:
diff --git a/src/main/python/systemds/scuro/drsearch/split_aware_evaluator.py b/src/main/python/systemds/scuro/drsearch/split_aware_evaluator.py
index 97de3da..05b70d0 100644
--- a/src/main/python/systemds/scuro/drsearch/split_aware_evaluator.py
+++ b/src/main/python/systemds/scuro/drsearch/split_aware_evaluator.py
@@ -63,9 +63,17 @@
class SplitAwareDAGEvaluator:
- def __init__(self, dag: RepresentationDag, modalities, task, node_cache=None):
+ def __init__(
+ self,
+ dag: RepresentationDag,
+ modalities,
+ task,
+ node_cache=None,
+ share_fitted=False,
+ ):
self.dag = dag
self.node_cache = node_cache
+ self.share_fitted = share_fitted
self.modalities = (
list(modalities) if isinstance(modalities, (list, tuple)) else [modalities]
)
@@ -231,10 +239,12 @@
return result
input_results = [execute_node(input_id) for input_id in node.inputs]
- reusable = (
- self.node_cache is not None
- and node_id != self.dag.root_node_id
- and not any(input_id in fitted for input_id in node.inputs)
+ reusable = self.node_cache is not None and (
+ self.share_fitted
+ or (
+ node_id != self.dag.root_node_id
+ and not any(input_id in fitted for input_id in node.inputs)
+ )
)
if reusable:
cached = self.node_cache.get(node_id, partition_key)
@@ -254,7 +264,7 @@
if requirement is not FitRequirement.NONE:
fitted.add(node_id)
- reusable = False
+ reusable = reusable and self.share_fitted
labels = None
fit_input = input_partitions[0]
fit_ids = (
diff --git a/src/main/python/systemds/scuro/drsearch/unimodal_optimizer.py b/src/main/python/systemds/scuro/drsearch/unimodal_optimizer.py
index ced501b..8b717e5 100644
--- a/src/main/python/systemds/scuro/drsearch/unimodal_optimizer.py
+++ b/src/main/python/systemds/scuro/drsearch/unimodal_optimizer.py
@@ -19,6 +19,7 @@
#
# -------------------------------------------------------------
import copy
+from collections import Counter
import math
import pickle
import time
@@ -44,6 +45,7 @@
AggregatedRepresentation,
)
from systemds.scuro.modality.modality import Modality
+from systemds.scuro.drsearch.algebra import AlgebraConfig
from systemds.scuro.drsearch.operator_registry import Registry
from systemds.scuro.representations.window_aggregation import WindowAggregation
from systemds.scuro.utils.checkpointing import CheckpointManager
@@ -71,10 +73,13 @@
max_num_workers: int = -1,
enable_checkpointing: bool = False,
window_combination_chains: int = 1,
+ algebra: Optional[AlgebraConfig] = None,
):
self._node_stats: Dict[str, Any] = {}
self.pruned = []
self.window_combination_chains = window_combination_chains
+ self.algebra = algebra or AlgebraConfig()
+ self.algebra_stats = Counter()
self.enable_checkpointing = enable_checkpointing
self.modalities = modalities
self.tasks = tasks
@@ -96,7 +101,8 @@
)
# TODO: check if we should make this a local variable (might keep unnecessary memory usage)
self.builders = {
- modality.modality_id: CSEAwareDAGBuilder() for modality in modalities
+ modality.modality_id: CSEAwareDAGBuilder(self.algebra, tasks, [modality])
+ for modality in modalities
}
self.debug = debug
@@ -392,6 +398,17 @@
exec_out = node_executor.run()
task_results.extend(exec_out["task_results"])
statistics = exec_out["statistics"]
+ reuse = statistics.get("reuse", {})
+ planned = reuse.get("planned_task_nodes", 0)
+ completed = reuse.get("completed_task_nodes", planned)
+ if reuse.get("deadlock") or completed < planned:
+ print(
+ f"[unimodal] WARNING: modality {modality.modality_id} "
+ f"evaluated {completed} of {planned} planned task nodes; "
+ f"deadlock={reuse.get('deadlock')} "
+ f"reason={reuse.get('deadlock_reason')}",
+ flush=True,
+ )
if fitted_dags:
fitted_task_dags = self._expand_dags_with_task_roots(fitted_dags)
diff --git a/src/main/python/systemds/scuro/modality/transformed.py b/src/main/python/systemds/scuro/modality/transformed.py
index 62cc03e..87cc3e9 100644
--- a/src/main/python/systemds/scuro/modality/transformed.py
+++ b/src/main/python/systemds/scuro/modality/transformed.py
@@ -185,10 +185,13 @@
fused_modality = TransformedModality(
self, fusion_method, ModalityType.EMBEDDING
)
+ modalities = self.create_modality_list(other)
start_time = time.time()
- fused_modality.data = fusion_method.transform(self.create_modality_list(other))
+ fused_modality.data = fusion_method.transform(modalities)
end_time = time.time()
- fused_modality.transform_time = end_time - start_time
+ fused_modality.transform_time += (end_time - start_time) + sum(
+ getattr(m, "transform_time", 0) or 0 for m in modalities[1:]
+ )
return fused_modality
def create_modality_list(self, other: Union[Modality, List[Modality]]):
diff --git a/src/main/python/systemds/scuro/models/model.py b/src/main/python/systemds/scuro/models/model.py
index fcd4150..965058e 100644
--- a/src/main/python/systemds/scuro/models/model.py
+++ b/src/main/python/systemds/scuro/models/model.py
@@ -22,6 +22,7 @@
class Model:
uses_gpu = None
+ feature_order_invariant = None
def __init__(self, name: str):
"""
diff --git a/src/main/python/systemds/scuro/representations/aggregate.py b/src/main/python/systemds/scuro/representations/aggregate.py
index 366bb9f..2c54913 100644
--- a/src/main/python/systemds/scuro/representations/aggregate.py
+++ b/src/main/python/systemds/scuro/representations/aggregate.py
@@ -51,18 +51,22 @@
if arr.ndim == 1:
values, counts = np.unique(arr, return_counts=True)
return values[np.argmax(counts)]
- moved = np.moveaxis(arr, axis, -1)
- flat = moved.reshape(-1, moved.shape[-1])
+ axes = tuple(
+ a % arr.ndim for a in (axis if isinstance(axis, tuple) else (axis,))
+ )
+ moved = np.moveaxis(arr, axes, tuple(range(arr.ndim - len(axes), arr.ndim)))
+ kept = moved.shape[: arr.ndim - len(axes)]
+ flat = moved.reshape(int(np.prod(kept)), -1)
modes = np.empty(flat.shape[0], dtype=arr.dtype)
for i in range(flat.shape[0]):
values, counts = np.unique(flat[i], return_counts=True)
modes[i] = values[np.argmax(counts)]
- return modes.reshape(moved.shape[:-1])
+ return modes.reshape(kept)
@staticmethod
def _normalize_axis(aggregate_dim):
if isinstance(aggregate_dim, tuple):
- return aggregate_dim[0] if aggregate_dim else 0
+ return aggregate_dim if aggregate_dim else 0
return aggregate_dim
_aggregation_function = {
diff --git a/src/main/python/systemds/scuro/representations/average.py b/src/main/python/systemds/scuro/representations/average.py
index 06c435d..ade8b03 100644
--- a/src/main/python/systemds/scuro/representations/average.py
+++ b/src/main/python/systemds/scuro/representations/average.py
@@ -32,14 +32,14 @@
@register_fusion_operator()
class Average(Fusion):
+ _laws = frozenset({"comm", "avg_flatten"})
+
def __init__(self, params=None):
"""
Combines modalities using averaging
"""
super().__init__("Average")
self.needs_alignment = True
- self.associative = True
- self.commutative = True
def execute(self, modalities: List[Modality]):
data = np.array(modalities[0].data, dtype=np.float64)
diff --git a/src/main/python/systemds/scuro/representations/clip.py b/src/main/python/systemds/scuro/representations/clip.py
index a64354b..55f841e 100644
--- a/src/main/python/systemds/scuro/representations/clip.py
+++ b/src/main/python/systemds/scuro/representations/clip.py
@@ -63,12 +63,13 @@
@register_representation([ModalityType.VIDEO, ModalityType.IMAGE])
@register_expensive_representation([ModalityType.VIDEO, ModalityType.IMAGE])
class CLIPVisual(UnimodalRepresentation):
+ OUTPUT_MODALITY_TYPE = ModalityType.EMBEDDING
supports_aggregation_pushdown = True
cache_in_worker = True
def __init__(self, output_file=None, batch_size=32, layer_name="", params=None):
parameters = self._get_parameters()
- super().__init__("CLIPVisual", ModalityType.EMBEDDING, parameters)
+ super().__init__("CLIPVisual", self.OUTPUT_MODALITY_TYPE, parameters)
self.params = params
self._activation_hook = None
self.model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
diff --git a/src/main/python/systemds/scuro/representations/concatenation.py b/src/main/python/systemds/scuro/representations/concatenation.py
index cee3038..f599aba 100644
--- a/src/main/python/systemds/scuro/representations/concatenation.py
+++ b/src/main/python/systemds/scuro/representations/concatenation.py
@@ -34,6 +34,14 @@
@register_fusion_operator()
class Concatenation(Fusion):
+ _laws = frozenset({"assoc", "concat_perm"})
+
+ @classmethod
+ def algebraic_laws(cls, parameters: dict) -> frozenset:
+ if parameters.get("preserve_leading_axis"):
+ return frozenset({"assoc"})
+ return cls._laws
+
def __init__(self, params=None, preserve_leading_axis=False):
super().__init__("Concatenation")
if params is not None:
diff --git a/src/main/python/systemds/scuro/representations/fusion.py b/src/main/python/systemds/scuro/representations/fusion.py
index 5cbe128..95bdec2 100644
--- a/src/main/python/systemds/scuro/representations/fusion.py
+++ b/src/main/python/systemds/scuro/representations/fusion.py
@@ -55,22 +55,28 @@
class Fusion(Representation):
+ _laws = frozenset()
+ preserves_leading_axis = False
+
def __init__(self, name, parameters=None):
"""
Parent class for different multimodal fusion types
:param name: Name of the fusion type
"""
super().__init__(name, parameters)
- self.associative = False
- self.commutative = False
+ self.associative = "assoc" in self._laws
+ self.commutative = "comm" in self._laws
self.needs_alignment = False
self.needs_training = False
self.needs_instance_alignment = False
- self.preserves_leading_axis = False
self.output_modality_type = ModalityType.EMBEDDING
self.data_type = np.float32
self.max_alignment_pad_ratio = DEFAULT_MAX_ALIGNMENT_PAD_RATIO
+ @classmethod
+ def algebraic_laws(cls, parameters: dict) -> frozenset:
+ return cls._laws
+
@staticmethod
def _as_stats_list(input_stats) -> List[RepresentationStats]:
if isinstance(input_stats, RepresentationStats):
diff --git a/src/main/python/systemds/scuro/representations/glove.py b/src/main/python/systemds/scuro/representations/glove.py
index 9513760..8e9cf0f 100644
--- a/src/main/python/systemds/scuro/representations/glove.py
+++ b/src/main/python/systemds/scuro/representations/glove.py
@@ -49,8 +49,10 @@
@register_representation(ModalityType.TEXT)
@register_expensive_representation(ModalityType.TEXT)
class GloVe(UnimodalRepresentation):
+ OUTPUT_MODALITY_TYPE = ModalityType.EMBEDDING
+
def __init__(self, output_file=None, params=None):
- super().__init__("GloVe", ModalityType.TEXT)
+ super().__init__("GloVe", self.OUTPUT_MODALITY_TYPE)
file_path = hf_hub_download(
repo_id="stanfordnlp/glove", filename="glove.6B.zip"
)
diff --git a/src/main/python/systemds/scuro/representations/hadamard.py b/src/main/python/systemds/scuro/representations/hadamard.py
index 0697a9f..a88d7d6 100644
--- a/src/main/python/systemds/scuro/representations/hadamard.py
+++ b/src/main/python/systemds/scuro/representations/hadamard.py
@@ -33,14 +33,14 @@
@register_fusion_operator()
class Hadamard(Fusion):
+ _laws = frozenset({"comm", "assoc"})
+
def __init__(self, params=None):
"""
Combines modalities using elementwise multiply (Hadamard product)
"""
super().__init__("Hadamard")
self.needs_alignment = True # zero padding falsifies the result
- self.commutative = True
- self.associative = True
def execute(self, modalities: List[Modality], train_indices=None):
fused_data = np.prod([m.data for m in modalities], axis=0)
diff --git a/src/main/python/systemds/scuro/representations/lstm.py b/src/main/python/systemds/scuro/representations/lstm.py
index 97b318e..46e0842 100644
--- a/src/main/python/systemds/scuro/representations/lstm.py
+++ b/src/main/python/systemds/scuro/representations/lstm.py
@@ -42,6 +42,7 @@
@register_fusion_operator()
class LSTM(Fusion):
fit_requirement = FitRequirement.SUPERVISED
+ preserves_leading_axis = True
def __init__(
self,
@@ -89,7 +90,6 @@
self.device = get_device()
self.needs_training = True
self.needs_alignment = False
- self.preserves_leading_axis = True
self.model = None
self.input_dim = None
self.num_classes = None
diff --git a/src/main/python/systemds/scuro/representations/max.py b/src/main/python/systemds/scuro/representations/max.py
index 2e0df4c..f221a0c 100644
--- a/src/main/python/systemds/scuro/representations/max.py
+++ b/src/main/python/systemds/scuro/representations/max.py
@@ -31,6 +31,8 @@
@register_fusion_operator()
class RowMax(Fusion):
+ _laws = frozenset({"comm", "assoc"})
+
def __init__(self, params=None):
"""
Combines modalities by computing the outer product of a modality combination and
@@ -38,8 +40,6 @@
"""
super().__init__("RowMax")
self.needs_alignment = True
- self.associative = True
- self.commutative = True
def execute(
self,
@@ -64,3 +64,14 @@
output_shape = max(stats_list, key=self._stats_num_elements).output_shape
output_shape_is_known = all(s.output_shape_is_known for s in stats_list)
return RepresentationStats(num_instances, output_shape, output_shape_is_known)
+
+ def estimate_peak_memory_bytes(self, input_stats) -> dict:
+ stats_list = self._as_stats_list(input_stats)
+ input_bytes = sum(self._stats_bytes(s) for s in stats_list)
+ output_bytes = self._stats_bytes(self.get_output_stats(input_stats))
+
+ raw_bytes = self._raw_input_bytes(input_stats)
+ cpu_peak = (
+ int((raw_bytes + 2 * input_bytes + output_bytes) * 1.15) + 8 * 1024 * 1024
+ )
+ return {"cpu_peak_bytes": cpu_peak, "gpu_peak_bytes": 0}
diff --git a/src/main/python/systemds/scuro/representations/openface.py b/src/main/python/systemds/scuro/representations/openface.py
index 60599e8..b2d82cc 100644
--- a/src/main/python/systemds/scuro/representations/openface.py
+++ b/src/main/python/systemds/scuro/representations/openface.py
@@ -37,6 +37,7 @@
from systemds.scuro.representations.representation import (
CONTAINER_LIST,
RepresentationStats,
+ stats_itemsize,
)
from systemds.scuro.representations.unimodal import UnimodalRepresentation
from systemds.scuro.representations.utils import get_sequence_lengths
@@ -170,6 +171,9 @@
FEATURE_DIM = len(FEATURE_COLUMNS)
FRAME_BATCH_SIZE = 32
+ MAX_SIDE = 1024
+ SIZE_WINDOW_CHUNKS = 8
+ DETECTOR_MEAN_BGR = (104, 117, 123)
FACE_MODEL_MEMORY_BYTES = 8 * 1024 * 1024
MULTITASK_MODEL_MEMORY_BYTES = 128 * 1024 * 1024
@@ -177,6 +181,7 @@
CPU_RUNTIME_OVERHEAD_BYTES = 128 * 1024 * 1024
GPU_RUNTIME_OVERHEAD_BYTES = 64 * 1024 * 1024
LANDMARK_RUNTIME_BYTES = 2400 * 1024 * 1024
+ DETECTOR_BYTES_PER_PIXEL = 144
LANDMARK_BYTES_PER_FRAME = 52 * 1024 * 1024
MULTITASK_BYTES_PER_FRAME = 26 * 1024 * 1024
@@ -186,6 +191,7 @@
confidence_threshold=0.02,
nms_threshold=0.4,
vis_threshold=0.5,
+ max_side=MAX_SIDE,
params=None,
):
if params is not None:
@@ -195,6 +201,7 @@
)
nms_threshold = params.get("nms_threshold", nms_threshold)
vis_threshold = params.get("vis_threshold", vis_threshold)
+ max_side = params.get("max_side", max_side)
parameters = {
"feature_set": list(self.FEATURE_SETS),
@@ -207,6 +214,7 @@
self.confidence_threshold = float(confidence_threshold)
self.nms_threshold = float(nms_threshold)
self.vis_threshold = float(vis_threshold)
+ self.max_side = int(max_side)
self.params = params
self.data_type = np.float32
self._gpu_id = None
@@ -300,9 +308,28 @@
max_height = int(getattr(input_stats, "max_height", 224))
max_width = int(getattr(input_stats, "max_width", 224))
max_channels = int(getattr(input_stats, "max_channels", 3))
- frame_bytes = (
- max_height * max_width * max_channels * np.dtype(np.float32).itemsize
+ float_size = np.dtype(np.float32).itemsize
+
+ if isinstance(input_stats, VideoStats):
+ video_bytes = (
+ int(input_stats.max_length)
+ * max_height
+ * max_width
+ * max_channels
+ * stats_itemsize(input_stats)
+ )
+ sample_bytes = 2 * video_bytes + 2 * max_height * max_width * 3 * float_size
+ else:
+ sample_bytes = 2 * max_height * max_width * max_channels
+
+ canvas_pixels = min(max_height, self.max_side) * min(max_width, self.max_side)
+ window_bytes = (
+ self.FRAME_BATCH_SIZE * self.SIZE_WINDOW_CHUNKS * canvas_pixels * 3
)
+ chunk_bytes = window_bytes + (
+ self.FRAME_BATCH_SIZE * canvas_pixels * 3 * 2 * float_size
+ )
+
model_bytes = self.FACE_MODEL_MEMORY_BYTES + self.MULTITASK_MODEL_MEMORY_BYTES
batch_bytes = self.MULTITASK_BYTES_PER_FRAME
runtime_bytes = self.GPU_RUNTIME_OVERHEAD_BYTES
@@ -315,13 +342,15 @@
return {
"cpu_peak_bytes": int(
output_bytes
- + frame_bytes
+ + sample_bytes
+ + chunk_bytes
+ model_bytes
+ self.CPU_RUNTIME_OVERHEAD_BYTES
),
"gpu_peak_bytes": int(
model_bytes
- + self.FRAME_BATCH_SIZE * (frame_bytes + batch_bytes)
+ + self.FRAME_BATCH_SIZE
+ * (canvas_pixels * self.DETECTOR_BYTES_PER_PIXEL + batch_bytes)
+ runtime_bytes
),
}
@@ -366,15 +395,54 @@
return transformed_modality
def _extract_image_features(self, images):
- return list(self._extract_batch_features([self._to_bgr(i) for i in images]))
+ return list(self._extract_streamed_features(images))
def _extract_video_features(self, frames, owner_id):
if len(frames) == 0:
raise ValueError(f"Video instance {owner_id} contains no frames")
- return self._extract_batch_features([self._to_bgr(frame) for frame in frames])
+ return self._extract_streamed_features(frames)
def _extract_features(self, image):
- return self._extract_batch_features([self._to_bgr(image)])[0]
+ return self._extract_batch_features([self._prepare(image)])[0]
+
+ def _extract_streamed_features(self, samples):
+ features = np.zeros((len(samples), self.feature_dim), dtype=np.float32)
+ window = self.FRAME_BATCH_SIZE * self.SIZE_WINDOW_CHUNKS
+ for window_start in range(0, len(samples), window):
+ indices = range(window_start, min(window_start + window, len(samples)))
+ prepared = {index: self._prepare(samples[index]) for index in indices}
+ order = sorted(indices, key=lambda index: prepared[index].shape[:2])
+ for start, stop in self._chunks(len(order)):
+ chunk = order[start:stop]
+ features[chunk] = self._extract_batch_features(
+ [prepared[index] for index in chunk]
+ )
+ return features
+
+ def _prepare(self, image):
+ image = self._to_bgr(image)
+ height, width = image.shape[:2]
+ longer = max(height, width)
+ if longer <= self.max_side:
+ return image
+ scale = self.max_side / longer
+ size = (max(1, round(width * scale)), max(1, round(height * scale)))
+ return cv2.resize(image, size, interpolation=cv2.INTER_AREA)
+
+ def _pad_to(self, image, height, width):
+ pad_bottom = height - image.shape[0]
+ pad_right = width - image.shape[1]
+ if not pad_bottom and not pad_right:
+ return image
+ return cv2.copyMakeBorder(
+ image,
+ 0,
+ pad_bottom,
+ 0,
+ pad_right,
+ cv2.BORDER_CONSTANT,
+ value=self.DETECTOR_MEAN_BGR,
+ )
def _extract_batch_features(self, images_bgr):
self._load_models()
@@ -460,53 +528,52 @@
detections = [None] * len(images_bgr)
priors_by_shape = {}
- by_shape = {}
- for index, image in enumerate(images_bgr):
- by_shape.setdefault(image.shape, []).append(index)
-
- for shape, indices in by_shape.items():
- height, width = shape[:2]
- if shape not in priors_by_shape:
- priors_by_shape[shape] = (
+ for start, stop in self._chunks(len(images_bgr)):
+ chunk = range(start, stop)
+ height = max(images_bgr[i].shape[0] for i in chunk)
+ width = max(images_bgr[i].shape[1] for i in chunk)
+ if (height, width) not in priors_by_shape:
+ priors_by_shape[(height, width)] = (
PriorBox(detector.cfg, image_size=(height, width))
.forward()
.to(detector.device)
.data
)
- priors = priors_by_shape[shape]
+ priors = priors_by_shape[(height, width)]
box_scale = torch.Tensor([width, height, width, height]).to(detector.device)
landmark_scale = torch.Tensor([width, height] * 5).to(detector.device)
- for start, stop in self._chunks(len(indices)):
- chunk = indices[start:stop]
- batch = torch.cat(
- [detector.preprocess_image(images_bgr[i])[0] for i in chunk]
+ batch = torch.cat(
+ [
+ detector.preprocess_image(
+ self._pad_to(images_bgr[i], height, width)
+ )[0]
+ for i in chunk
+ ]
+ )
+ with torch.no_grad():
+ locations, confidences, landmark_offsets = detector.model(batch)
+
+ for row, index in enumerate(chunk):
+ boxes = decode(locations.data[row], priors, variance)
+ boxes = (boxes * box_scale).cpu().numpy()
+ scores = confidences[row].data.cpu().numpy()[:, 1]
+ points = decode_landm(landmark_offsets.data[row], priors, variance)
+ points = (points * landmark_scale).cpu().numpy()
+
+ keep = np.where(scores > detector.confidence_threshold)[0]
+ boxes, points, scores = boxes[keep], points[keep], scores[keep]
+ dets = np.hstack((boxes, scores[:, np.newaxis])).astype(
+ np.float32, copy=False
)
- with torch.no_grad():
- locations, confidences, landmark_offsets = detector.model(batch)
+ keep = py_cpu_nms(dets, detector.nms_threshold)
+ dets = np.concatenate((dets[keep], points[keep]), axis=1)
- for row, index in enumerate(chunk):
- boxes = decode(locations.data[row], priors, variance)
- boxes = (boxes * box_scale).cpu().numpy()
- scores = confidences[row].data.cpu().numpy()[:, 1]
- points = decode_landm(landmark_offsets.data[row], priors, variance)
- points = (points * landmark_scale).cpu().numpy()
-
- keep = np.where(scores > detector.confidence_threshold)[0]
- boxes, points, scores = boxes[keep], points[keep], scores[keep]
- dets = np.hstack((boxes, scores[:, np.newaxis])).astype(
- np.float32, copy=False
- )
- keep = py_cpu_nms(dets, detector.nms_threshold)
- dets = np.concatenate((dets[keep], points[keep]), axis=1)
-
- if len(dets) == 0 or dets[0][4] < detector.vis_threshold:
- continue
- bbox = dets[0][:4].astype(int)
- faces[index] = images_bgr[index][
- bbox[1] : bbox[3], bbox[0] : bbox[2]
- ]
- detections[index] = dets
+ if len(dets) == 0 or dets[0][4] < detector.vis_threshold:
+ continue
+ bbox = dets[0][:4].astype(int)
+ faces[index] = images_bgr[index][bbox[1] : bbox[3], bbox[0] : bbox[2]]
+ detections[index] = dets
return faces, detections
def _predict_multitask(self, faces, needs_backbone):
diff --git a/src/main/python/systemds/scuro/representations/sum.py b/src/main/python/systemds/scuro/representations/sum.py
index 58cac2e..0b9ce2e 100644
--- a/src/main/python/systemds/scuro/representations/sum.py
+++ b/src/main/python/systemds/scuro/representations/sum.py
@@ -33,6 +33,8 @@
@register_fusion_operator()
class Sum(Fusion):
+ _laws = frozenset({"comm", "assoc"})
+
def __init__(self, params=None):
"""
Combines modalities using colum-wise sum
@@ -41,16 +43,13 @@
self.needs_alignment = True
def execute(self, modalities: List[Modality]):
- data = np.array(
- modalities[0].data,
- dtype=modalities[0].metadata[0]["data_layout"]["type"],
- )
-
- for m in range(1, len(modalities)):
- data += np.asarray(
- modalities[m].data,
- dtype=modalities[m].metadata[0]["data_layout"]["type"],
- )
+ arrays = [
+ np.asarray(m.data, dtype=m.metadata[0]["data_layout"]["type"])
+ for m in modalities
+ ]
+ data = np.array(arrays[0], dtype=np.result_type(*arrays))
+ for array in arrays[1:]:
+ data += array
return data
def get_output_stats(self, input_stats_list) -> RepresentationStats:
diff --git a/src/main/python/systemds/scuro/representations/swin_video_transformer.py b/src/main/python/systemds/scuro/representations/swin_video_transformer.py
index 5bf4a6d..2172e6a 100644
--- a/src/main/python/systemds/scuro/representations/swin_video_transformer.py
+++ b/src/main/python/systemds/scuro/representations/swin_video_transformer.py
@@ -55,6 +55,7 @@
@register_representation([ModalityType.VIDEO])
@register_expensive_representation([ModalityType.VIDEO])
class SwinVideoTransformer(FrameBudgetedVideoModel, UnimodalRepresentation):
+ OUTPUT_MODALITY_TYPE = ModalityType.EMBEDDING
_EMBED_DIM = 768
cache_in_worker = True
_MEMORY_PROFILE = VideoMemoryProfile(
@@ -80,7 +81,7 @@
# "batch_size": [1, 2, 4, 8, 16, 32],
}
self.data_type = torch.float32
- super().__init__("SwinVideoTransformer", ModalityType.EMBEDDING, parameters)
+ super().__init__("SwinVideoTransformer", self.OUTPUT_MODALITY_TYPE, parameters)
if params is not None:
layer_name = params.get("layer_name", layer_name)
batch_size = int(params.get("batch_size", batch_size))
diff --git a/src/main/python/systemds/scuro/representations/vgg.py b/src/main/python/systemds/scuro/representations/vgg.py
index 6f82937..214b56a 100644
--- a/src/main/python/systemds/scuro/representations/vgg.py
+++ b/src/main/python/systemds/scuro/representations/vgg.py
@@ -56,6 +56,7 @@
@register_representation([ModalityType.IMAGE, ModalityType.VIDEO])
@register_expensive_representation([ModalityType.IMAGE, ModalityType.VIDEO])
class VGG19(UnimodalRepresentation):
+ OUTPUT_MODALITY_TYPE = ModalityType.EMBEDDING
supports_aggregation_pushdown = True
cache_in_worker = True
@@ -71,7 +72,7 @@
self.model = models.vgg19(weights=models.VGG19_Weights.DEFAULT)
self.model = self.model.to(self.device)
parameters = self._get_parameters()
- super().__init__("VGG19", ModalityType.EMBEDDING, parameters)
+ super().__init__("VGG19", self.OUTPUT_MODALITY_TYPE, parameters)
self.params = params
if params is not None:
batch_size = int((params or {}).get("batch_size", batch_size))
diff --git a/src/main/python/systemds/scuro/representations/window_aggregation.py b/src/main/python/systemds/scuro/representations/window_aggregation.py
index e8189e9..60b5b46 100644
--- a/src/main/python/systemds/scuro/representations/window_aggregation.py
+++ b/src/main/python/systemds/scuro/representations/window_aggregation.py
@@ -693,10 +693,11 @@
if isinstance(self.aggregation_function, Aggregation):
return in_shape[1:]
length = in_shape[0] if in_shape else 0
- approx_window_size = (
- max(1, int(length / self._effective_num_windows(length))) if in_shape else 1
- )
- return self._per_window_feature_shape(approx_window_size)
+ # Window sizes grow geometrically and _pad_stack pads every window's
+ # features to the largest one, so size them from the largest window
+ # (about 4x the mean), not the mean.
+ max_window_size = int(self._window_sizes(length).max()) if in_shape else 1
+ return self._per_window_feature_shape(max_window_size)
def get_output_stats(self, input_stats: RepresentationStats) -> tuple:
in_shape = tuple(int(s) for s in input_stats.output_shape)
diff --git a/src/main/python/systemds/scuro/representations/zero_pad.py b/src/main/python/systemds/scuro/representations/zero_pad.py
new file mode 100644
index 0000000..505545e
--- /dev/null
+++ b/src/main/python/systemds/scuro/representations/zero_pad.py
@@ -0,0 +1,48 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+from systemds.scuro.modality.transformed import TransformedModality
+from systemds.scuro.representations.fusion import Fusion
+from systemds.scuro.representations.representation import derive_stats
+from systemds.scuro.representations.unimodal import UnimodalRepresentation
+
+
+class ZeroPad(UnimodalRepresentation):
+ def __init__(self, width=None, input_width=None, params=None):
+ super().__init__("ZeroPad", None)
+ params = params or {}
+ self.width = params.get("width", width)
+ self.input_width = params.get("input_width", input_width)
+
+ def transform(self, modality, aggregation=None):
+ padded = TransformedModality(
+ modality, self, self_contained=modality.self_contained
+ )
+ padded.data = modality.data
+ Fusion._normalize_for_fusion(padded)
+ padded.pad(max_len=self.width)
+ return padded
+
+ def get_output_stats(self, input_stats):
+ shape = (*input_stats.output_shape[:-1], self.width)
+ return derive_stats(input_stats, output_shape=shape)
+
+ def get_current_parameters(self):
+ return {"width": self.width, "input_width": self.input_width}
diff --git a/src/main/python/tests/scuro/test_algebra_canonical.py b/src/main/python/tests/scuro/test_algebra_canonical.py
new file mode 100644
index 0000000..9801d48
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_canonical.py
@@ -0,0 +1,271 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import json
+import unittest
+
+from systemds.scuro.drsearch.algebra import ALL_LAWS, canonical_id
+from systemds.scuro.drsearch.representation_dag import RepresentationDAGBuilder
+from systemds.scuro.representations.aggregated_representation import (
+ AggregatedRepresentation,
+)
+from systemds.scuro.representations.average import Average
+from systemds.scuro.representations.bow import BoW
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.hadamard import Hadamard
+from systemds.scuro.representations.lstm import LSTM
+from systemds.scuro.representations.max import RowMax
+from systemds.scuro.representations.sum import Sum
+from systemds.scuro.representations.zero_pad import ZeroPad
+
+IMPLICIT = AggregatedRepresentation(
+ target_dimensions=1, aggregate_leading=True
+).get_current_parameters()
+FEATURE_AGG = AggregatedRepresentation(target_dimensions=1).get_current_parameters()
+NONE = frozenset()
+
+
+def S(*c, op=Sum):
+ return (op, {}, *c)
+
+
+def A(*c):
+ return (Average, {}, *c)
+
+
+def C(*c):
+ return (Concatenation, {"preserve_leading_axis": False}, *c)
+
+
+def W(*c):
+ return (Concatenation, {"preserve_leading_axis": True}, *c)
+
+
+def L(*c):
+ return (LSTM, {}, *c)
+
+
+def G(c, params=IMPLICIT):
+ return (AggregatedRepresentation, params, c)
+
+
+def P(c):
+ return (ZeroPad, ZeroPad(8, 3).get_current_parameters(), c)
+
+
+def pushed(c, params):
+ return (BoW, {"_pushdown_aggregation": params}, c)
+
+
+# (name, left, right, laws, invariant, equal)
+CASES = [
+ ("leaf", "a", "a", ALL_LAWS, False, True),
+ ("leaf_index", "a", "b", ALL_LAWS, False, False),
+ ("params", (BoW, {"x": 1}, "a"), (BoW, {"x": 2}, "a"), ALL_LAWS, False, False),
+ *[
+ (
+ f"comm_{op.__name__}",
+ S("a", "b", op=op),
+ S("b", "a", op=op),
+ ALL_LAWS,
+ False,
+ True,
+ )
+ for op in (Sum, RowMax, Hadamard)
+ ],
+ ("comm_off", S("a", "b"), S("b", "a"), NONE, False, False),
+ ("assoc", S(S("a", "b"), "c"), S("a", S("b", "c")), ALL_LAWS, False, True),
+ ("assoc_nary", S(S("a", "b"), "c"), S("a", "b", "c"), ALL_LAWS, False, True),
+ ("assoc_off", S(S("a", "b"), "c"), S("a", "b", "c"), {"comm"}, False, False),
+ (
+ "assoc_other_op",
+ S(S("a", "b", op=RowMax), "c"),
+ S("a", "b", "c"),
+ ALL_LAWS,
+ False,
+ False,
+ ),
+ ("concat_order_kept", C("a", "b"), C("b", "a"), ALL_LAWS, False, False),
+ ("concat_perm", C("a", "b"), C("b", "a"), ALL_LAWS, True, True),
+ ("concat_assoc", C(C("a", "b"), "c"), C("a", C("b", "c")), ALL_LAWS, False, True),
+ ("window_concat_never_sorted", W("a", "b"), W("b", "a"), ALL_LAWS, True, False),
+ (
+ "window_concat_assoc",
+ W(W("a", "b"), "c"),
+ W("a", "b", "c"),
+ ALL_LAWS,
+ True,
+ True,
+ ),
+ ("window_concat_not_concat", W("a", "b"), C("a", "b"), ALL_LAWS, True, False),
+ ("lstm_never_sorted", L("a", "b"), L("b", "a"), ALL_LAWS, True, False),
+ (
+ "no_comm_below_lstm",
+ L(S("a", "b"), "c"),
+ L(S("b", "a"), "c"),
+ ALL_LAWS,
+ True,
+ False,
+ ),
+ (
+ "no_perm_below_lstm",
+ L(C("a", "b"), "c"),
+ L(C("b", "a"), "c"),
+ ALL_LAWS,
+ True,
+ False,
+ ),
+ (
+ "pad_below_lstm",
+ L(S(P("a"), "b"), "c"),
+ L(S("a", "b"), "c"),
+ ALL_LAWS,
+ False,
+ True,
+ ),
+ (
+ "comm_above_lstm",
+ S(L("a", "b"), "c"),
+ S("c", L("a", "b")),
+ ALL_LAWS,
+ False,
+ True,
+ ),
+ ("avg_flatten", A(A("a", "b"), "c"), A("c", A("b", "a")), ALL_LAWS, False, True),
+ ("avg_not_assoc", A(A("a", "b"), "c"), A("a", A("b", "c")), ALL_LAWS, False, False),
+ (
+ "avg_weights_add",
+ A(A("a", "b"), A("a", "c")),
+ A("a", A("b", "c")),
+ ALL_LAWS,
+ False,
+ True,
+ ),
+ (
+ "avg_flatten_off",
+ A(A("a", "b"), A("a", "c")),
+ A("a", A("b", "c")),
+ {"comm"},
+ False,
+ False,
+ ),
+ (
+ "agg_pushdown",
+ pushed("a", FEATURE_AGG),
+ G((BoW, {}, "a"), FEATURE_AGG),
+ ALL_LAWS,
+ False,
+ True,
+ ),
+ (
+ "agg_pushdown_off",
+ pushed("a", FEATURE_AGG),
+ G((BoW, {}, "a"), FEATURE_AGG),
+ NONE,
+ False,
+ False,
+ ),
+ ("implicit_agg", S(G("a"), "b"), S("a", "b"), ALL_LAWS, False, True),
+ (
+ "implicit_agg_pushed",
+ S(pushed("a", IMPLICIT), "b"),
+ S((BoW, {}, "a"), "b"),
+ ALL_LAWS,
+ False,
+ True,
+ ),
+ (
+ "implicit_agg_off",
+ S(G("a"), "b"),
+ S("a", "b"),
+ ALL_LAWS - {"implicit_agg"},
+ False,
+ False,
+ ),
+ (
+ "implicit_agg_other_params",
+ S(G("a", FEATURE_AGG), "b"),
+ S("a", "b"),
+ ALL_LAWS,
+ False,
+ False,
+ ),
+ ("implicit_agg_window_concat", W(G("a"), "b"), W("a", "b"), ALL_LAWS, False, False),
+ ("implicit_agg_lstm", L(G("a"), "b"), L("a", "b"), ALL_LAWS, False, False),
+ ("implicit_agg_unary", (BoW, {}, G("a")), (BoW, {}, "a"), ALL_LAWS, False, False),
+ ("implicit_pad", S(P("a"), "b"), S("a", "b"), ALL_LAWS, False, True),
+ ("implicit_pad_avg", A(P("a"), "b"), A("a", "b"), ALL_LAWS, False, True),
+ (
+ "implicit_pad_off",
+ S(P("a"), "b"),
+ S("a", "b"),
+ ALL_LAWS - {"implicit_pad"},
+ False,
+ False,
+ ),
+ ("implicit_pad_concat", C(P("a"), "b"), C("a", "b"), ALL_LAWS, True, False),
+]
+
+
+def build(expr):
+ builder = RepresentationDAGBuilder()
+ leaves = {}
+
+ def make(e):
+ if isinstance(e, str):
+ if e not in leaves:
+ i = "abc".index(e)
+ leaves[e] = builder.create_leaf_node(f"m{i}", i)
+ return leaves[e]
+ op, params, *children = e
+ return builder.create_operation_node(op, [make(c) for c in children], params)
+
+ return builder.build(make(expr))
+
+
+class TestCanonicalId(unittest.TestCase):
+ def test_canonical_pairs(self):
+ for name, left, right, laws, invariant, equal in CASES:
+ with self.subTest(name):
+ laws = frozenset(laws)
+ left, right = build(left), build(right)
+ ids = [canonical_id(dag, laws, invariant) for dag in (left, right)]
+ self.assertEqual(ids[0] == ids[1], equal)
+ for dag, dag_id in zip((left, right), ids):
+ spec = json.loads(json.dumps(dag.to_spec()))
+ self.assertEqual(canonical_id(spec, laws, invariant), dag_id)
+
+ def test_dag_method_defaults(self):
+ dag = build(C("a", S("c", "b")))
+ self.assertEqual(dag.canonical_id(), canonical_id(dag, ALL_LAWS, False))
+ self.assertNotEqual(dag.canonical_id(laws=NONE), dag.canonical_id())
+
+ def test_sub_plan_signature(self):
+ dag = build(C("a", S("b", "c")))
+ sub = dag.get_node_by_id(dag.get_node_by_id(dag.root_node_id).inputs[1])
+ self.assertEqual(
+ canonical_id(dag, ALL_LAWS, False, sub.node_id),
+ canonical_id(build(S("c", "b")), ALL_LAWS, False),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_cse.py b/src/main/python/tests/scuro/test_algebra_cse.py
new file mode 100644
index 0000000..df86a55
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_cse.py
@@ -0,0 +1,82 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import unittest
+from types import SimpleNamespace
+
+from systemds.scuro.drsearch.algebra import AlgebraConfig
+from systemds.scuro.drsearch.unimodal_optimizer import UnimodalOptimizer
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.sum import Sum
+
+LEGACY = AlgebraConfig()
+LAW_AWARE = AlgebraConfig(cse_law_aware=True)
+FULL = AlgebraConfig.full()
+
+
+def _builder(algebra):
+ optimizer = UnimodalOptimizer(
+ [SimpleNamespace(modality_id=0)],
+ [SimpleNamespace(model=SimpleNamespace(name="task"), expected_dim=1)],
+ debug=False,
+ window_combination_chains=2,
+ algebra=algebra,
+ )
+ builder = optimizer.builders[0]
+ return optimizer, builder, [builder.create_leaf_node(0, i) for i in range(2)]
+
+
+def _nodes(builder, op):
+ return [n for n in builder.global_nodes if n.operation is op]
+
+
+class TestLawAwareCSE(unittest.TestCase):
+ def test_window_concatenation_rotations(self):
+ for algebra, expected in ((LEGACY, 1), (LAW_AWARE, 2), (FULL, 2)):
+ with self.subTest(algebra=algebra):
+ optimizer, builder, windows = _builder(algebra)
+ optimizer.combine_windowed_representations(builder, windows)
+ self.assertEqual(len(_nodes(builder, Concatenation)), expected)
+ prevented = builder.algebra_stats["cse_unsafe_merges_prevented"]
+ self.assertEqual(prevented, int(algebra.cse_law_aware))
+
+ def test_commutative_fusion_merges_in_both_modes(self):
+ cases = [
+ (Sum, {}, LEGACY, 1),
+ (Sum, {}, LAW_AWARE, 1),
+ (Concatenation, {"preserve_leading_axis": False}, LEGACY, 1),
+ (Concatenation, {"preserve_leading_axis": False}, LAW_AWARE, 2),
+ (Concatenation, {"preserve_leading_axis": False}, FULL, 1),
+ ]
+ for op, params, algebra, expected in cases:
+ with self.subTest(op=op.__name__, algebra=algebra):
+ _, builder, (a, b) = _builder(algebra)
+ builder.create_operation_node(op, [a, b], params)
+ builder.create_operation_node(op, [b, a], params)
+ self.assertEqual(len(_nodes(builder, op)), expected)
+ merged_by_law = builder.algebra_stats["cse_merges_by_law"]
+ self.assertEqual(
+ merged_by_law, int(algebra.cse_law_aware and expected == 1)
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_exhaustive.py b/src/main/python/tests/scuro/test_algebra_exhaustive.py
new file mode 100644
index 0000000..249720e
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_exhaustive.py
@@ -0,0 +1,76 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import tempfile
+import unittest
+
+from systemds.scuro.drsearch.algebra import ALL_LAWS, AlgebraConfig, canonical_id
+from systemds.scuro.drsearch.multimodal_optimizer import MultimodalOptimizer
+from systemds.scuro.representations.average import Average
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.sum import Sum
+from tests.scuro.test_multimodal_ga_optimizer import (
+ _FakeModality,
+ _FakeUnimodalResults,
+ _make_task,
+)
+
+PERMUTATIONS = AlgebraConfig(exhaustive_dedup_permutations=True)
+CANONICAL = AlgebraConfig(
+ laws=ALL_LAWS,
+ feature_order_invariant=True,
+ exhaustive_dedup_permutations=True,
+ exhaustive_canonical=True,
+)
+
+
+def _fusion_dags(algebra, n=3):
+ ids = [f"m{i}" for i in range(n)]
+ with tempfile.TemporaryDirectory() as tmp:
+ optimizer = MultimodalOptimizer(
+ [_FakeModality(m) for m in ids],
+ _FakeUnimodalResults({m: [object()] for m in ids}),
+ [_make_task()],
+ debug=False,
+ checkpoint_dir=tmp,
+ algebra=algebra,
+ )
+ optimizer.fusion_operators = [Concatenation, Sum, Average]
+ dags = list(optimizer._generate_fusion_dags(ids, {m: 0 for m in ids}))
+ return dags, optimizer.algebra_stats
+
+
+class TestExhaustiveDedup(unittest.TestCase):
+ def test_counts(self):
+ legacy, _ = _fusion_dags(AlgebraConfig())
+ ordered, stats = _fusion_dags(PERMUTATIONS)
+ self.assertEqual((len(legacy), len(ordered)), (648, 108))
+ self.assertEqual(stats["exhaustive_generated"], 648)
+ self.assertEqual(stats["exhaustive_after_permutation_dedup"], 108)
+
+ def test_canonical_matches_brute_force(self):
+ ordered, _ = _fusion_dags(PERMUTATIONS)
+ ids = {canonical_id(d, ALL_LAWS, True) for d in ordered}
+ canonical, stats = _fusion_dags(CANONICAL)
+ self.assertEqual(
+ sorted(canonical_id(d, ALL_LAWS, True) for d in canonical), sorted(ids)
+ )
+ self.assertEqual(stats["exhaustive_after_canonical"], len(ids))
diff --git a/src/main/python/tests/scuro/test_algebra_explicit_prep.py b/src/main/python/tests/scuro/test_algebra_explicit_prep.py
new file mode 100644
index 0000000..0f6ff05
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_explicit_prep.py
@@ -0,0 +1,242 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import itertools
+import tempfile
+import unittest
+from collections import Counter
+from types import SimpleNamespace
+
+import numpy as np
+
+from systemds.scuro.drsearch.algebra import (
+ ALL_LAWS,
+ AlgebraConfig,
+ canonical_id,
+ count_explicit_prep,
+ sample_stats,
+ typecheck,
+)
+from systemds.scuro.drsearch.operator_registry import Registry
+from systemds.scuro.drsearch.multimodal_ga_optimizer import (
+ DagGenome,
+ MultimodalDeapOptimizer,
+)
+from systemds.scuro.drsearch.multimodal_optimizer import MultimodalOptimizer
+from systemds.scuro.drsearch.representation_dag import CSEAwareDAGBuilder
+from systemds.scuro.modality.transformed import TransformedModality
+from systemds.scuro.modality.type import ModalityType
+from systemds.scuro.representations.aggregated_representation import (
+ AggregatedRepresentation,
+)
+from systemds.scuro.representations.average import Average
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.hadamard import Hadamard
+from systemds.scuro.representations.representation import RepresentationStats
+from systemds.scuro.representations.sum import Sum
+from systemds.scuro.representations.zero_pad import ZeroPad
+from tests.scuro.data_generator import ModalityRandomDataGenerator
+from tests.scuro.test_multimodal_ga_optimizer import (
+ _FakeModality,
+ _FakeUnimodalResults,
+ _make_task,
+)
+
+AGG = AlgebraConfig(explicit_fusion_prep=True)
+PAD = AlgebraConfig(explicit_padding="pad")
+BOTH = AlgebraConfig(explicit_fusion_prep=True, explicit_padding="pad")
+FUSIONS = [Concatenation, Sum, Average, Hadamard]
+ALIGNED = {Sum, Average, Hadamard}
+
+
+def _reps():
+ """Modality 0 is a (4, 8) sequence; 1 and 2 are vectors of width 3 and 8."""
+ gen = ModalityRandomDataGenerator()
+ data, _ = gen.create_2d_modality(6, (4, 8))
+ seq = TransformedModality(gen, "test")
+ seq.data = data
+ reps = {0: seq}
+ for mid, width in ((1, 3), (2, 8)):
+ reps[mid] = gen.create1DModality(6, width, ModalityType.TIMESERIES)
+ for mid, rep in reps.items():
+ rep.modality_id = mid
+ return {mid: [rep] for mid, rep in reps.items()}
+
+
+def _expected(algebra, op, leaves):
+ out = []
+ if algebra.explicit_fusion_prep and 0 in leaves:
+ out.append(("AggregatedRepresentation", 0))
+ # Padding needs every operand to be a vector first.
+ if algebra.explicit_padding == "pad" and op in ALIGNED:
+ if algebra.explicit_fusion_prep or 0 not in leaves:
+ out.append(("ZeroPad", 1))
+ return out
+
+
+def _prep_nodes(dag):
+ return sorted(
+ (n.operation.__name__, dag.get_node_by_id(n.inputs[0]).modality_id)
+ for n in dag.filter_connected_nodes(dag.nodes)
+ if n.operation in (AggregatedRepresentation, ZeroPad)
+ )
+
+
+def _fused(dag, reps):
+ out = dag.execute([m for ms in reps.values() for m in ms], enable_cache=False)
+ return np.asarray(out[dag.root_node_id] if isinstance(out, dict) else out.data)
+
+
+def _assert_equivalent(legacy, explicit, reps, expected):
+ assert _prep_nodes(legacy) == []
+ assert _prep_nodes(explicit) == expected
+ assert canonical_id(legacy, ALL_LAWS, False) == canonical_id(
+ explicit, ALL_LAWS, False
+ )
+ np.testing.assert_array_equal(_fused(legacy, reps), _fused(explicit, reps))
+
+
+def _ga_dag(algebra, reps, genome):
+ optimizer = MultimodalDeapOptimizer(
+ [_FakeModality(m) for m in reps],
+ _FakeUnimodalResults(reps),
+ [_make_task()],
+ debug=False,
+ algebra=algebra,
+ )
+ optimizer._current_task_name = "task0"
+ return optimizer._genome_to_dag(genome)
+
+
+class TestExplicitPrep(unittest.TestCase):
+ def test_ga_genome_to_dag(self):
+ cases = itertools.product([AGG, PAD, BOTH], FUSIONS, [(0, 1), (1, 2)])
+ for algebra, op, leaves in cases:
+ with self.subTest(algebra=algebra, op=op.__name__, leaves=leaves):
+ reps = _reps()
+ genome = DagGenome([(m, 0) for m in leaves], (0, 1), {"": op})
+ _assert_equivalent(
+ _ga_dag(AlgebraConfig(), reps, genome),
+ _ga_dag(algebra, reps, genome),
+ reps,
+ _expected(algebra, op, leaves),
+ )
+
+ def test_exhaustive_enumerator(self):
+ for algebra in (AGG, BOTH):
+ with self.subTest(algebra=algebra):
+ reps = _reps()
+ runs = []
+ for explicit in (AlgebraConfig(), algebra):
+ with tempfile.TemporaryDirectory() as tmp:
+ optimizer = MultimodalOptimizer(
+ [_FakeModality(m) for m in reps],
+ _FakeUnimodalResults(reps),
+ [_make_task()],
+ debug=False,
+ checkpoint_dir=tmp,
+ algebra=AlgebraConfig(
+ exhaustive_dedup_permutations=True,
+ explicit_fusion_prep=explicit.explicit_fusion_prep,
+ explicit_padding=explicit.explicit_padding,
+ ),
+ )
+ optimizer.fusion_operators = FUSIONS
+ runs.append(
+ list(
+ optimizer._generate_fusion_dags(
+ [0, 1], {0: 0, 1: 0}, "task0"
+ )
+ )
+ )
+ self.assertEqual(len(runs[0]), 2 * len(FUSIONS))
+ self.assertEqual(len(runs[1]), 2 * len(FUSIONS))
+ for legacy, explicit, op in zip(*runs, FUSIONS * 2):
+ _assert_equivalent(
+ legacy, explicit, reps, _expected(algebra, op, (0, 1))
+ )
+ self.assertEqual(
+ optimizer.algebra_stats["explicit_prep_plans"], 2 * len(FUSIONS)
+ )
+
+ def test_counts_and_typechecks_padded_plan(self):
+ reps = _reps()
+ dag = _ga_dag(BOTH, reps, DagGenome([(0, 0), (1, 0)], (0, 1), {"": Sum}))
+ stats = Counter()
+ count_explicit_prep(stats, dag)
+ self.assertEqual(
+ stats,
+ {
+ "explicit_prep_plans": 1,
+ "explicit_padding_plans": 1,
+ "explicit_aggregations": 1,
+ "explicit_paddings": 1,
+ "explicit_padding_ratio_sum": 8 / 3,
+ "explicit_padding_ratio_max": 8 / 3,
+ },
+ )
+ leaves = {(m, 0): r[0] for m, r in reps.items()}
+ types = {k: r.modality_type for k, r in leaves.items()}
+ leaf_stats = {k: sample_stats(r) for k, r in leaves.items()}
+ self.assertEqual(typecheck(dag, types, leaf_stats), [])
+
+ def test_zero_pad_is_not_registered(self):
+ registry = Registry()
+ lists = [
+ registry._representations,
+ registry._context_operators,
+ registry._dimensionality_reduction_operators,
+ ]
+ for ops in (ops for lst in lists for ops in lst.values()):
+ self.assertNotIn(ZeroPad, ops)
+
+ def test_unbound_genome_to_dag_and_bare_checker(self):
+ reps = _reps()
+ genome = DagGenome([(0, 0), (1, 0)], (0, 1), {"": Sum})
+ dag = MultimodalDeapOptimizer._genome_to_dag(None, genome)
+ self.assertEqual(_prep_nodes(dag), [])
+ checker = object.__new__(MultimodalDeapOptimizer)
+ checker.max_feature_dim = None
+ checker.k_best_representations = {"task0": reps}
+ self.assertIsNone(checker._oversized_reason("task0", genome))
+
+ def test_cse_builder(self):
+ for params, wrapped in (({}, True), ({"preserve_leading_axis": True}, False)):
+ with self.subTest(params=params):
+ leaf = SimpleNamespace(
+ modality_id="t", stats=RepresentationStats(5, (4, 8))
+ )
+ builder = CSEAwareDAGBuilder(BOTH, leaves=[leaf])
+ leaf_id = builder.create_leaf_node("t")
+ root = builder.create_operation_node(
+ Concatenation, [leaf_id, leaf_id], params
+ )
+ builder.build(root)
+ self.assertEqual(
+ builder.get_node(root).inputs != [leaf_id, leaf_id], wrapped
+ )
+ self.assertEqual(
+ builder.algebra_stats["explicit_aggregations"], 2 * wrapped
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_ga_dedup.py b/src/main/python/tests/scuro/test_algebra_ga_dedup.py
new file mode 100644
index 0000000..8bc457d
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_ga_dedup.py
@@ -0,0 +1,65 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import unittest
+from unittest.mock import patch
+
+from systemds.scuro.drsearch.algebra import AlgebraConfig
+from systemds.scuro.drsearch.multimodal_ga_optimizer import DagGenome
+from systemds.scuro.representations.sum import Sum
+from tests.scuro.test_multimodal_ga_optimizer import MODULE, _make_optimizer
+
+
+def _body(_dag, _task, _modalities, _objective_specs):
+ return (0.5,), {
+ "train_score": {},
+ "val_score": {"accuracy": 0.5},
+ "test_score": {},
+ "runtime": 2.0,
+ "task_time": 1.0,
+ "representation_time": 1.0,
+ }
+
+
+class TestCanonicalGADedup(unittest.TestCase):
+ def test_commuted_sum_evaluated_once_when_on(self):
+ leaves = [("m0", 0), ("m1", 0)]
+ genomes = [DagGenome(leaves, tree, {"": Sum}) for tree in ((0, 1), (1, 0))]
+ for algebra, evaluations in (
+ (AlgebraConfig(), 2),
+ (AlgebraConfig(laws=frozenset({"comm"}), ga_canonical_dedup=True), 1),
+ ):
+ with self.subTest(algebra=algebra):
+ optimizer, _, task = _make_optimizer(
+ n_modalities=2, max_workers=1, algebra=algebra
+ )
+ optimizer._current_task_name = task.model.name
+ population = [optimizer._make_individual(g) for g in genomes]
+ with patch(f"{MODULE}._evaluate_genome_body", side_effect=_body):
+ optimizer._evaluate_population(population, task)
+ results = optimizer.optimization_results["task0"]
+ self.assertEqual(len(results), evaluations)
+ avoided = 2 - evaluations
+ stats = optimizer.algebra_stats
+ self.assertEqual(stats["ga_evaluations_avoided"], avoided)
+ self.assertEqual(stats["ga_seconds_avoided"], 2.0 * avoided)
+ self.assertEqual(stats["ga_unique_legacy"], 2 * avoided)
+ self.assertEqual(stats["ga_unique_canonical"], avoided)
diff --git a/src/main/python/tests/scuro/test_algebra_laws.py b/src/main/python/tests/scuro/test_algebra_laws.py
new file mode 100644
index 0000000..c0d55dd
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_laws.py
@@ -0,0 +1,144 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import itertools
+import unittest
+
+import numpy as np
+
+from systemds.scuro.drsearch.operator_registry import Registry
+from systemds.scuro.modality.modality import Modality
+from systemds.scuro.modality.transformed import TransformedModality
+from systemds.scuro.modality.type import ModalityType
+from systemds.scuro.representations.concatenation import Concatenation
+
+OPERATORS = [(op, {}) for op in Registry().get_fusion_operators()] + [
+ (Concatenation, {"preserve_leading_axis": True})
+]
+DECLARED = [
+ (op, params, law)
+ for op, params in OPERATORS
+ for law in sorted(op.algebraic_laws(params))
+]
+WIDTHS = {"equal": (6, 6, 6), "unequal": (6, 3, 9)}
+DTYPES = {
+ "float32": (np.float32, np.float32, np.float32),
+ "float32/64": (np.float32, np.float64, np.float32),
+ "int/float": (np.int64, np.float32, np.float64),
+}
+
+
+def _operands(widths, dtypes, rank):
+ rng = np.random.default_rng(0)
+ shape = (5,) + (4,) * (rank - 1)
+ return [
+ (rng.standard_normal(shape + (w,)) * 5).astype(dtype)
+ for w, dtype in zip(widths, dtypes)
+ ]
+
+
+def _fuse(op, params, *operands):
+ modalities = []
+ for data in operands:
+ metadata = [ModalityType.EMBEDDING.create_metadata(d) for d in data]
+ modality = TransformedModality(
+ Modality(ModalityType.EMBEDDING, 0, metadata), "operand"
+ )
+ modality.data = data
+ modalities.append(modality)
+ return np.asarray(op(params).transform(modalities))
+
+
+def _assert_same(x, y, exact):
+ assert x.shape == y.shape
+ if exact:
+ np.testing.assert_array_equal(x, y)
+ else:
+ np.testing.assert_allclose(x, y, rtol=1e-5, atol=1e-5)
+
+
+def _prepared(x, width):
+ # What _prepare_modalities hands to execute: hidden mean aggregation, then zero-padding.
+ x = x.mean(axis=1) if x.ndim == 3 else x
+ return np.pad(x, ((0, 0), (0, width - x.shape[-1])))
+
+
+def _comm(f, a, b, c, exact):
+ _assert_same(f(a, b), f(b, a), exact)
+
+
+def _assoc(f, a, b, c, exact):
+ left = f(f(a, b), c)
+ _assert_same(left, f(a, f(b, c)), exact)
+ _assert_same(left, f(a, b, c), exact)
+
+
+def _avg_flatten(f, a, b, c, exact):
+ width = max(x.shape[-1] for x in (a, b, c))
+ expected = sum(w * _prepared(x, width) for w, x in ((0.25, a), (0.25, b), (0.5, c)))
+ _assert_same(f(f(a, b), c), expected, exact)
+
+
+def _concat_perm(f, a, b, c, exact):
+ _assert_same(f(a, b), np.roll(f(b, a), -b.shape[-1], axis=-1), exact=True)
+
+
+LAW_CHECKS = {
+ "comm": _comm,
+ "assoc": _assoc,
+ "avg_flatten": _avg_flatten,
+ "concat_perm": _concat_perm,
+}
+
+
+class TestAlgebraLaws(unittest.TestCase):
+ def test_every_declared_law_is_checked(self):
+ self.assertLessEqual({law for _, _, law in DECLARED}, set(LAW_CHECKS))
+
+ def test_declared_law_holds(self):
+ cases = itertools.product(DECLARED, WIDTHS, DTYPES, (1, 2))
+ for (op, params, law), widths, dtypes, rank in cases:
+ with self.subTest(
+ op=f"{op.__name__}{params or ''}",
+ law=law,
+ widths=widths,
+ dtypes=dtypes,
+ rank=rank,
+ ):
+ LAW_CHECKS[law](
+ lambda *xs: _fuse(op, params, *xs),
+ *_operands(WIDTHS[widths], DTYPES[dtypes], rank),
+ exact=op is Concatenation,
+ )
+
+ def test_window_axis_concatenation_is_order_sensitive(self):
+ params = {"preserve_leading_axis": True}
+ a, b, _ = _operands(WIDTHS["equal"], DTYPES["float32"], rank=2)
+ self.assertFalse(
+ np.array_equal(
+ _fuse(Concatenation, params, a, b), _fuse(Concatenation, params, b, a)
+ )
+ )
+ self.assertFalse({"comm", "concat_perm"} & Concatenation.algebraic_laws(params))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_order_invariant.py b/src/main/python/tests/scuro/test_algebra_order_invariant.py
new file mode 100644
index 0000000..1f6c126
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_order_invariant.py
@@ -0,0 +1,56 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import unittest
+from types import SimpleNamespace
+
+from systemds.scuro.drsearch.algebra import AlgebraConfig, order_invariant
+from systemds.scuro.models.model import Model
+
+
+def _tasks(*flags):
+ return [
+ SimpleNamespace(model=SimpleNamespace(feature_order_invariant=f)) for f in flags
+ ]
+
+
+class TestOrderInvariant(unittest.TestCase):
+ def test_model_default_is_unknown(self):
+ self.assertIsNone(Model("m").feature_order_invariant)
+
+ def test_cases(self):
+ cases = [
+ (None, _tasks(True, True), True),
+ (None, _tasks(True, None), False),
+ (None, _tasks(True, False), False),
+ (None, [], False),
+ (None, [SimpleNamespace(model=object())], False),
+ (True, _tasks(False), True),
+ (False, _tasks(True), False),
+ ]
+ for override, tasks, expected in cases:
+ with self.subTest(override=override, tasks=tasks):
+ config = AlgebraConfig(feature_order_invariant=override)
+ self.assertIs(order_invariant(config, tasks), expected)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_plan_counts.py b/src/main/python/tests/scuro/test_algebra_plan_counts.py
new file mode 100644
index 0000000..8861d89
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_plan_counts.py
@@ -0,0 +1,71 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import unittest
+
+from systemds.scuro.drsearch.algebra import (
+ ALL_LAWS,
+ _brute_force_canonical,
+ count_fusion_plans,
+ count_search_space,
+)
+from systemds.scuro.representations.average import Average
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.hadamard import Hadamard
+from systemds.scuro.representations.lstm import LSTM
+from systemds.scuro.representations.max import RowMax
+from systemds.scuro.representations.sum import Sum
+
+COMM = frozenset({"comm", "concat_perm"})
+COMM_ASSOC = [Sum, Hadamard, RowMax]
+MIXED = [Sum, Average, Concatenation, LSTM]
+
+
+class TestPlanCounts(unittest.TestCase):
+ def test_closed_forms_match_brute_force(self):
+ for ops in (COMM_ASSOC, MIXED):
+ for n in (2, 3, 4):
+ with self.subTest(ops=[op.__name__ for op in ops], n=n):
+ counts = count_fusion_plans(n, ops, ALL_LAWS, True)
+ for key, laws in (
+ ("ordered", frozenset()),
+ ("commutative", COMM),
+ ("canonical", ALL_LAWS),
+ ):
+ brute = _brute_force_canonical(n, ops, laws, True)
+ self.assertEqual(counts[key], brute, key)
+
+ def test_known_values(self):
+ counts = count_fusion_plans(3, [Concatenation, Sum, Average], ALL_LAWS, True)
+ self.assertEqual((counts["legacy_exhaustive"], counts["ordered"]), (648, 108))
+ # comm alone divides the ordered trees by 2^(n-1)
+ self.assertEqual(
+ count_fusion_plans(4, COMM_ASSOC, COMM, True)["commutative"], 3240 // 8
+ )
+ self.assertIsNone(count_fusion_plans(5, MIXED, ALL_LAWS, True)["canonical"])
+
+ def test_search_space_sums_over_subsets(self):
+ per_size = {n: count_fusion_plans(n, MIXED, ALL_LAWS, False) for n in (2, 3)}
+ total = count_search_space([2, 2, 3], MIXED, ALL_LAWS, False)
+ for key, value in total.items():
+ self.assertEqual(
+ value, (4 + 6 + 6) * per_size[2][key] + 12 * per_size[3][key], key
+ )
diff --git a/src/main/python/tests/scuro/test_algebra_signatures.py b/src/main/python/tests/scuro/test_algebra_signatures.py
new file mode 100644
index 0000000..d180978
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_signatures.py
@@ -0,0 +1,90 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import unittest
+
+from systemds.scuro.drsearch.algebra import ANY_TYPE, operator_signature
+from systemds.scuro.drsearch.operator_registry import Registry, load_builtin_operators
+from systemds.scuro.modality.type import ModalityType
+from systemds.scuro.representations.aggregated_representation import (
+ AggregatedRepresentation,
+)
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.lstm import LSTM
+from systemds.scuro.representations.mlp_averaging import MLPAveraging
+from systemds.scuro.representations.sum import Sum
+from systemds.scuro.representations.window_aggregation import WindowAggregation
+
+load_builtin_operators(ANY_TYPE, include_fusion=True)
+REGISTERED = sorted(
+ {
+ (op, kind, modality)
+ for kind, lists in [
+ ("encode", Registry._representations),
+ ("context", Registry._context_operators),
+ ("reduce", Registry._dimensionality_reduction_operators),
+ ]
+ for modality, ops in lists.items()
+ for op in ops
+ },
+ key=lambda t: (t[0].__name__, t[1], t[2].name),
+) + [(op, "fuse", None) for op in Registry().get_fusion_operators()]
+
+RANK_CASES = [
+ (Sum, {}, ("fuse", "n", "vector", "vector")),
+ (
+ Concatenation,
+ {"preserve_leading_axis": False},
+ ("fuse", "n", "vector", "vector"),
+ ),
+ (Concatenation, {"preserve_leading_axis": True}, ("fuse", "n", "seq", "seq")),
+ (LSTM, {}, ("fuse", "n", "seq", "vector")),
+ (AggregatedRepresentation, {}, ("aggregate", 1, "seq", "vector")),
+ (WindowAggregation, {}, ("context", 1, "seq", "windows")),
+ (MLPAveraging, {}, ("reduce", 1, "vector", "vector")),
+ (None, {}, ("task", 1, "any", "any")),
+]
+
+
+class TestOperatorSignatures(unittest.TestCase):
+ def test_every_registered_operator_has_a_signature(self):
+ for op, kind, modality in REGISTERED:
+ with self.subTest(
+ op=op.__name__, kind=kind, modality=getattr(modality, "name", None)
+ ):
+ sig = operator_signature(op)
+ self.assertEqual(sig.kind, kind)
+ if modality is not None:
+ self.assertIn(modality, sig.input_types)
+ if kind in ("encode", "fuse"):
+ self.assertIsInstance(sig.output_type, ModalityType)
+
+ def test_rank_rules(self):
+ for op, params, expected in RANK_CASES:
+ with self.subTest(op=getattr(op, "__name__", None), params=params):
+ sig = operator_signature(op, params)
+ self.assertEqual(
+ (sig.kind, sig.arity, sig.input_rank, sig.output_rank), expected
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_stats.py b/src/main/python/tests/scuro/test_algebra_stats.py
new file mode 100644
index 0000000..619c686
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_stats.py
@@ -0,0 +1,65 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import itertools
+import unittest
+
+import numpy as np
+
+from systemds.scuro.drsearch.operator_registry import Registry
+from systemds.scuro.representations.representation import RepresentationStats
+from tests.scuro.test_algebra_laws import _fuse
+
+# Other test modules register fusion operators of their own.
+OPERATORS = [
+ op
+ for op in Registry().get_fusion_operators()
+ if op.__module__.startswith("systemds.") and not op().needs_training
+]
+OPERANDS = {
+ "equal": [(5, 6), (5, 6)],
+ "unequal": [(5, 6), (5, 3)],
+ "sequence": [(5, 4, 6), (5, 3)],
+}
+
+
+class TestPredictedStats(unittest.TestCase):
+ def test_predicted_stats_match_execution(self):
+ for op, case in itertools.product(OPERATORS, OPERANDS):
+ with self.subTest(op=op.__name__, case=case):
+ rng = np.random.default_rng(0)
+ operands = [
+ rng.standard_normal(shape).astype(np.float32)
+ for shape in OPERANDS[case]
+ ]
+ stats = [RepresentationStats(len(x), x.shape[1:]) for x in operands]
+ fused = _fuse(op, {}, *operands)
+ self.assertEqual(
+ tuple(op().get_output_stats(stats).output_shape), fused.shape[1:]
+ )
+ self.assertGreater(
+ op().estimate_peak_memory_bytes(stats)["cpu_peak_bytes"],
+ fused.nbytes,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_subtree_sharing.py b/src/main/python/tests/scuro/test_algebra_subtree_sharing.py
new file mode 100644
index 0000000..fbaa7f7
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_subtree_sharing.py
@@ -0,0 +1,127 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+import unittest
+from unittest.mock import patch
+
+import numpy as np
+
+from systemds.scuro.drsearch import multimodal_ga_optimizer as ga
+from systemds.scuro.drsearch.algebra import AlgebraConfig
+from systemds.scuro.drsearch.representation_dag import RepresentationDAGBuilder
+from systemds.scuro.drsearch.trial_result_cache import TrialResultCache
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.lstm import LSTM
+from tests.scuro.data_generator import TestSVM, TestTask
+from tests.scuro.test_multimodal_ga_optimizer import (
+ _build_real_optimizer,
+ _make_real_representation,
+)
+
+LSTM_PARAMS = {"width": 8, "epochs": 2}
+SPECS = [("accuracy", "max")]
+
+
+class _RecordingSVM(TestSVM):
+ inputs = []
+
+ def fit(self, X, y, X_test, y_test):
+ self.inputs.append(np.array(X))
+ return super().fit(X, y, X_test, y_test)
+
+
+def _dag(leaves, root_op=None):
+ b = RepresentationDAGBuilder()
+ ids = [b.create_leaf_node(i, 0) for i in leaves]
+ node = b.create_operation_node(LSTM, ids[:2], dict(LSTM_PARAMS))
+ if root_op is not None:
+ node = b.create_operation_node(root_op, [node, ids[2]], {})
+ return b.build(node)
+
+
+def _evaluate(task, modalities, dag, share):
+ _RecordingSVM.inputs = []
+ _, payload = ga._evaluate_genome_body(dag, task, modalities, SPECS, share)
+ return payload, _RecordingSVM.inputs
+
+
+class TestSubtreeSharing(unittest.TestCase):
+ def setUp(self):
+ cache = patch.object(ga, "_SUBTREE_CACHE", TrialResultCache(2**30, 0.0))
+ cache.start()
+ self.addCleanup(cache.stop)
+ self.task = TestTask("share_task", "share_model", 40)
+ self.task.model = _RecordingSVM("share_model")
+ self.modalities = [_make_real_representation(i, 40, 4) for i in range(3)]
+ _RecordingSVM.inputs = []
+
+ def test_fitted_subtree_is_reused_bit_identically(self):
+ task, modalities = self.task, self.modalities
+ splits = len(task.evaluation_splits)
+ _evaluate(task, modalities, _dag([0, 1]), True)
+ shared, shared_inputs = _evaluate(
+ task, modalities, _dag([0, 1, 2], Concatenation), True
+ )
+ _, fresh_inputs = _evaluate(
+ task, modalities, _dag([0, 1, 2], Concatenation), False
+ )
+
+ self.assertEqual(shared["subtree_cache"]["hits"], splits)
+ self.assertEqual(len(shared_inputs), splits)
+ self.assertEqual(len(fresh_inputs), splits)
+ for a, b in zip(shared_inputs, fresh_inputs):
+ np.testing.assert_array_equal(a, b)
+
+ def test_operand_order_is_part_of_the_key(self):
+ _evaluate(self.task, self.modalities, _dag([0, 1]), True)
+ swapped, _ = _evaluate(self.task, self.modalities, _dag([1, 0]), True)
+ self.assertEqual(swapped["subtree_cache"]["hits"], 0)
+
+ def test_switch_controls_call_and_stats(self):
+ payload = {
+ "train_score": {},
+ "val_score": {"accuracy": 0.5},
+ "test_score": {},
+ "runtime": 1.0,
+ "task_time": 0.5,
+ "representation_time": 0.5,
+ "subtree_cache": {
+ "hits": 3,
+ "misses": 2,
+ "saved_seconds": 4.0,
+ "bytes": 10,
+ },
+ }
+ for share in (False, True):
+ with self.subTest(share=share):
+ optimizer, task = _build_real_optimizer(
+ fusion_ops=[LSTM], algebra=AlgebraConfig(share_fused_subtrees=share)
+ )
+ genome = optimizer._random_genome(task.model.name)
+ with patch.object(
+ ga, "_evaluate_genome_body", return_value=((0.5,), payload)
+ ) as body:
+ optimizer._evaluate_genome(genome, task)
+ self.assertEqual(len(body.call_args.args), 4 + share)
+ self.assertEqual(optimizer.algebra_stats["subtree_cache_hits"], 3)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_algebra_typecheck.py b/src/main/python/tests/scuro/test_algebra_typecheck.py
new file mode 100644
index 0000000..4e34a3b
--- /dev/null
+++ b/src/main/python/tests/scuro/test_algebra_typecheck.py
@@ -0,0 +1,133 @@
+# -------------------------------------------------------------
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# -------------------------------------------------------------
+
+import unittest
+from types import SimpleNamespace
+
+import numpy as np
+
+from systemds.scuro.drsearch.algebra import AlgebraConfig, typecheck
+from systemds.scuro.drsearch.multimodal_ga_optimizer import (
+ DagGenome,
+ MultimodalDeapOptimizer,
+)
+from systemds.scuro.drsearch.representation_dag import (
+ CSEAwareDAGBuilder,
+ RepresentationDAGBuilder,
+)
+from systemds.scuro.modality.type import ModalityType
+from systemds.scuro.representations.bow import BoW
+from systemds.scuro.representations.concatenation import Concatenation
+from systemds.scuro.representations.mfcc import MFCC
+from systemds.scuro.representations.representation import RepresentationStats
+from systemds.scuro.representations.sum import Sum
+from tests.scuro.test_multimodal_ga_optimizer import _FakeUnimodalResults, _make_task
+
+TEXT, EMB = ModalityType.TEXT, ModalityType.EMBEDDING
+SEQ, VEC = RepresentationStats(5, (4, 8)), RepresentationStats(5, (3,))
+
+
+def _check(op, params, leaves, with_stats=True):
+ builder = RepresentationDAGBuilder()
+ ids = [builder.create_leaf_node(f"m{i}", 0) for i in range(len(leaves))]
+ dag = builder.build(builder.create_operation_node(op, ids, params))
+ types = {(f"m{i}", 0): t for i, (t, _) in enumerate(leaves)}
+ stats = {(f"m{i}", 0): s for i, (_, s) in enumerate(leaves)}
+ violations = typecheck(dag, types, stats if with_stats else None)
+ return sorted((v.severity, v.kind) for v in violations)
+
+
+VIOLATION_CASES = [
+ (BoW, {}, [(TEXT, None)], []),
+ (MFCC, {}, [(TEXT, None)], [("error", "input_type")]),
+ (Sum, {}, [(EMB, VEC)], [("error", "arity")]),
+ (BoW, {}, [(TEXT, None), (TEXT, None)], [("error", "arity")]),
+ (Sum, {}, [(EMB, VEC), (EMB, VEC)], []),
+ (
+ Sum,
+ {},
+ [(EMB, SEQ), (EMB, VEC)],
+ [("implicit", "hidden_aggregation"), ("implicit", "zero_padding")],
+ ),
+ (
+ Concatenation,
+ {},
+ [(EMB, SEQ), (EMB, VEC)],
+ [("implicit", "hidden_aggregation")],
+ ),
+ (Concatenation, {"preserve_leading_axis": True}, [(EMB, SEQ), (EMB, SEQ)], []),
+]
+
+
+class TestTypecheck(unittest.TestCase):
+ def test_violations(self):
+ for i, (op, params, leaves, expected) in enumerate(VIOLATION_CASES):
+ with self.subTest(case=i, op=op.__name__):
+ self.assertEqual(_check(op, params, leaves), expected)
+
+ def test_shapes_unknown_without_leaf_stats(self):
+ violations = _check(Sum, {}, [(EMB, SEQ), (EMB, VEC)], with_stats=False)
+ self.assertEqual(violations, [])
+
+ def test_builder_modes(self):
+ for mode, raises, counted in [
+ ("off", False, 0),
+ ("warn", False, 1),
+ ("strict", True, 1),
+ ]:
+ with self.subTest(mode=mode):
+ modality = SimpleNamespace(
+ modality_id="t", modality_type=TEXT, stats=None
+ )
+ builder = CSEAwareDAGBuilder(
+ AlgebraConfig(typecheck=mode), leaves=[modality]
+ )
+ root = builder.create_operation_node(
+ MFCC, [builder.create_leaf_node("t")], {}
+ )
+ if raises:
+ with self.assertRaisesRegex(ValueError, "input_type"):
+ builder.build(root)
+ else:
+ builder.build(root)
+ self.assertEqual(builder.algebra_stats["typecheck_input_type"], counted)
+
+ def test_ga_counts_hidden_operators(self):
+ reps = {
+ "m0": [SimpleNamespace(modality_type=EMB, data=np.zeros((5, 4, 8)))],
+ "m1": [SimpleNamespace(modality_type=EMB, data=np.zeros((5, 3)))],
+ }
+ optimizer = MultimodalDeapOptimizer(
+ [SimpleNamespace(modality_id=m) for m in reps],
+ _FakeUnimodalResults(reps),
+ [_make_task()],
+ debug=False,
+ algebra=AlgebraConfig(typecheck="strict"),
+ )
+ genome = DagGenome([("m0", 0), ("m1", 0)], (0, 1), {"": Sum})
+ self.assertIsNone(optimizer._oversized_reason("task0", genome))
+ stats = optimizer.algebra_stats
+ self.assertEqual(stats["typecheck_hidden_aggregation"], 1)
+ self.assertEqual(stats["typecheck_zero_padding:Sum"], 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/main/python/tests/scuro/test_data_loaders.py b/src/main/python/tests/scuro/test_data_loaders.py
index 0d5ace0..4affe2d 100644
--- a/src/main/python/tests/scuro/test_data_loaders.py
+++ b/src/main/python/tests/scuro/test_data_loaders.py
@@ -19,6 +19,8 @@
#
# -------------------------------------------------------------
+import json
+import os
import shutil
import unittest
import numpy as np
@@ -26,6 +28,7 @@
from tests.scuro.data_generator import setup_data
from systemds.scuro.dataloader.audio_loader import AudioLoader, AudioStats
from systemds.scuro.dataloader.image_loader import ImageLoader, ImageStats
+from systemds.scuro.dataloader.json_loader import JSONLoader
from systemds.scuro.dataloader.text_loader import TextLoader, TextStats
from systemds.scuro.dataloader.video_loader import VideoLoader, VideoStats
from systemds.scuro.modality.type import ModalityType
@@ -183,5 +186,36 @@
# self.assertEqual(stats.num_signals, 2)
+class TestJSONLoaderFields(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.test_file_path = "test_json_fields_data"
+ os.makedirs(cls.test_file_path, exist_ok=True)
+ cls.indices = ["0", "1"]
+ cls.json_path = os.path.join(cls.test_file_path, "dialogue.json")
+ with open(cls.json_path, "w") as f:
+ json.dump(
+ {
+ index: {"utterance": f"utt {index}", "context": ["a", "b"]}
+ for index in cls.indices
+ },
+ f,
+ )
+
+ @classmethod
+ def tearDownClass(cls):
+ shutil.rmtree(cls.test_file_path)
+
+ def test_single_field(self):
+ data, _ = JSONLoader(self.json_path, self.indices, "utterance").load()
+ self.assertEqual(data, ["utt 0", "utt 1"])
+
+ def test_list_of_fields_is_joined_in_order(self):
+ loader = JSONLoader(self.json_path, self.indices, ["context", "utterance"])
+ data, _ = loader.load()
+ self.assertEqual(data, ["a b utt 0", "a b utt 1"])
+ self.assertEqual(loader.stats.max_length, len("a b utt 0"))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/src/main/python/tests/scuro/test_multimodal_ga_optimizer.py b/src/main/python/tests/scuro/test_multimodal_ga_optimizer.py
index f79157f..e5eac1c 100644
--- a/src/main/python/tests/scuro/test_multimodal_ga_optimizer.py
+++ b/src/main/python/tests/scuro/test_multimodal_ga_optimizer.py
@@ -36,6 +36,7 @@
_collect_leaf_indices,
_failure_fitness,
_objective_value,
+ _task_uses_gpu,
)
from systemds.scuro.drsearch.operator_registry import Registry, register_fusion_operator
from systemds.scuro.modality.type import ModalityType
@@ -110,10 +111,20 @@
class _SynchronousPool:
instances = []
- def __init__(self, n_workers, dispatch, ctx=None, threads_per_worker=1):
+ def __init__(
+ self,
+ n_workers,
+ dispatch,
+ ctx=None,
+ threads_per_worker=1,
+ gpu_devices=None,
+ gpu_slots_per_device=1,
+ gpu_demand_fraction=1.0,
+ ):
self.n_workers = n_workers
self.dispatch = dispatch
self.threads_per_worker = threads_per_worker
+ self.gpu_devices = list(gpu_devices or [])
self.pending = None
self.next_job_id = 0
self.shutdown_called = False
@@ -123,7 +134,18 @@
def has_idle_worker(self):
return self.pending is None
- def submit(self, kind, payload, gpu_id=None):
+ @property
+ def has_idle_cpu_worker(self):
+ return self.pending is None
+
+ @property
+ def gpu_worker_devices(self):
+ return []
+
+ def has_idle_worker_for(self, gpu_id, allow_gpu_worker_for_cpu=False):
+ return self.pending is None
+
+ def submit(self, kind, payload, gpu_id=None, allow_gpu_worker_for_cpu=False):
job_id = self.next_job_id
self.next_job_id += 1
try:
@@ -1216,5 +1238,53 @@
self.assertGreaterEqual(result.runtime, 0.0)
+class _LanePool:
+ """Idle-worker bookkeeping of PersistentWorkerPool, without processes."""
+
+ def __init__(self, idle_gpu, idle_cpu):
+ self.idle_gpu = set(idle_gpu)
+ self.idle_cpu = idle_cpu
+ self.gpu_worker_devices = [0, 1]
+
+ @property
+ def has_idle_cpu_worker(self):
+ return self.idle_cpu
+
+ def has_idle_worker_for(self, gpu_id, allow_gpu_worker_for_cpu=False):
+ if gpu_id is not None:
+ return gpu_id in self.idle_gpu
+ return self.idle_cpu or (allow_gpu_worker_for_cpu and bool(self.idle_gpu))
+
+
+class TestGpuLanes(unittest.TestCase):
+ def _lane(self, pool, uses_gpu):
+ return MultimodalDeapOptimizer._idle_lane(
+ SimpleNamespace(_worker_pool=pool), uses_gpu
+ )
+
+ def test_gpu_task_prefers_idle_gpu_worker(self):
+ self.assertEqual(self._lane(_LanePool({1}, idle_cpu=True), True), 1)
+
+ def test_gpu_task_overflows_to_cpu_worker(self):
+ self.assertIsNone(self._lane(_LanePool(set(), idle_cpu=True), True))
+
+ def test_cpu_task_may_take_gpu_worker(self):
+ self.assertIsNone(self._lane(_LanePool({0}, idle_cpu=False), False))
+
+ def test_no_idle_worker(self):
+ self.assertIs(self._lane(_LanePool(set(), idle_cpu=False), True), False)
+ self.assertIs(self._lane(_LanePool(set(), idle_cpu=False), False), False)
+
+ def test_task_uses_gpu_from_flag_or_device(self):
+ def task(**model_attrs):
+ return SimpleNamespace(model=SimpleNamespace(**model_attrs))
+
+ self.assertTrue(_task_uses_gpu(task(uses_gpu=True)))
+ self.assertFalse(_task_uses_gpu(task(uses_gpu=False, device="cuda")))
+ self.assertTrue(_task_uses_gpu(task(device=SimpleNamespace(type="cuda"))))
+ self.assertFalse(_task_uses_gpu(task(device=SimpleNamespace(type="cpu"))))
+ self.assertFalse(_task_uses_gpu(task()))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/src/main/python/tests/scuro/test_unimodal_optimizer.py b/src/main/python/tests/scuro/test_unimodal_optimizer.py
index f27c721..6e002c1 100644
--- a/src/main/python/tests/scuro/test_unimodal_optimizer.py
+++ b/src/main/python/tests/scuro/test_unimodal_optimizer.py
@@ -47,6 +47,7 @@
from systemds.scuro.representations.bow import BoW
from systemds.scuro.representations.bert import Bert
from systemds.scuro.representations.word2vec import W2V
+from systemds.scuro.representations.window_aggregation import Window
from tests.scuro.test_unimodal_representations import (
PHYSIOLOGICAL_REPRESENTATIONS,
TIMESERIES_REPRESENTATIONS,
@@ -162,6 +163,43 @@
self.assertEqual(indices, [0, 5])
self.assertTrue(all(np.isfinite(entry.robustness_score) for entry in robust))
+ def test_robust_results_penalise_fold_variation(self):
+ modality = SimpleNamespace(modality_id="modality")
+ task = SimpleNamespace(model=SimpleNamespace(name="task"))
+ results = UnimodalResults([modality], [task], k=2)
+ entries = [
+ ResultEntry(
+ val_score={"accuracy": 0.90},
+ val_fold_scores={"accuracy": [0.70, 0.80, 0.90, 1.00, 1.10]},
+ dag=SimpleNamespace(
+ nodes=[SimpleNamespace(operation=UnimodalOptimizer)]
+ ),
+ ),
+ ResultEntry(
+ val_score={"accuracy": 0.86},
+ val_fold_scores={"accuracy": [0.85, 0.86, 0.87, 0.85, 0.87]},
+ dag=SimpleNamespace(
+ nodes=[SimpleNamespace(operation=UnimodalOptimizer)]
+ ),
+ ),
+ ]
+ results.results[modality.modality_id][task.model.name] = entries
+
+ robust, indices = results.get_k_most_robust_results(
+ modality, task, "accuracy", one_se_parsimony=False
+ )
+
+ self.assertEqual(robust, [entries[1], entries[0]])
+ self.assertEqual(indices, [1, 0])
+ self.assertAlmostEqual(
+ entries[0].robustness_score,
+ 0.90 - np.std(entries[0].val_fold_scores["accuracy"], ddof=1),
+ )
+ self.assertAlmostEqual(
+ entries[1].robustness_score,
+ 0.86 - np.std(entries[1].val_fold_scores["accuracy"], ddof=1),
+ )
+
def test_bow_and_tfidf_require_dimensionality_reduction_before_task(self):
text_data, text_md = ModalityRandomDataGenerator().create_text_data(
self.num_instances, 10
@@ -194,6 +232,84 @@
task_input = dag.get_node_by_id(task_node.inputs[0])
self.assertIs(task_input.operation, MLPAveraging)
+ def _window_inputs(self, modality, representations):
+ """(input operation, input rank) of every window node in the search."""
+ with patch.object(
+ Registry,
+ "_representations",
+ registry_for(modality.modality_type, representations),
+ ):
+ optimizer = UnimodalOptimizer(
+ [modality], self.tasks, False, enable_checkpointing=False
+ )
+ dags, _, _ = optimizer._build_execution_dags_for_modality(modality)
+ stats = {}
+ for dag in dags:
+ stats.update(optimizer._node_output_stats(dag))
+ inputs = []
+ for dag in dags:
+ for node in dag.nodes:
+ if node.operation is None or not issubclass(node.operation, Window):
+ continue
+ source = dag.get_node_by_id(node.inputs[0])
+ inputs.append(
+ (
+ source.operation,
+ len(stats[source.node_id].output_shape),
+ )
+ )
+ return optimizer, inputs
+
+ def test_windows_are_not_applied_to_rank_one_representation_outputs(self):
+ text_data, text_md = ModalityRandomDataGenerator().create_text_data(
+ self.num_instances, 10
+ )
+ text = UnimodalModality(
+ TestDataLoader(
+ self.indices, None, ModalityType.TEXT, text_data, str, text_md
+ )
+ )
+ audio_data, audio_md = ModalityRandomDataGenerator().create_audio_data(
+ self.num_instances, 4000
+ )
+ audio = UnimodalModality(
+ TestDataLoader(
+ self.indices, None, ModalityType.AUDIO, audio_data, np.float32, audio_md
+ )
+ )
+ # W2V pools to one vector per instance; Concatenation flattens the
+ # (frames, features) outputs of Spectral and ZeroCrossing to rank 1.
+ for modality, representations in (
+ (text, [W2V]),
+ (audio, [Spectral, ZeroCrossing]),
+ ):
+ with self.subTest(modality=modality.modality_type.name):
+ optimizer, inputs = self._window_inputs(modality, representations)
+ representation_inputs = [
+ (operation, rank)
+ for operation, rank in inputs
+ if operation is not None
+ ]
+ self.assertEqual(representation_inputs, [])
+ self.assertTrue(
+ any("no time axis" in p["reason"] for p in optimizer.pruned)
+ )
+
+ def test_windows_are_kept_for_representations_with_a_time_axis(self):
+ video_data, video_md = ModalityRandomDataGenerator().create_visual_modality(
+ self.num_instances, 10, 10, 10
+ )
+ video = UnimodalModality(
+ TestDataLoader(
+ self.indices, None, ModalityType.VIDEO, video_data, np.float32, video_md
+ )
+ )
+ _, inputs = self._window_inputs(video, [ResNet])
+ self.assertGreater(len(inputs), 0)
+ for operation, rank in inputs:
+ self.assertIs(operation, ResNet)
+ self.assertGreaterEqual(rank, 2)
+
def test_unimodal_optimizer_for_image_modality(self):
image_data, image_md = ModalityRandomDataGenerator().create_visual_modality(
self.num_instances, 1, 10, 10
@@ -453,3 +569,71 @@
modalities[0], self.tasks[0], "accuracy"
)
assert len(result) == 1
+
+
+class TestSelectionStrategies(unittest.TestCase):
+ """The three selection strategies must genuinely disagree.
+
+ They are only worth carrying separately through HPO and fusion if they can
+ pick different winners out of the same pool, so the fixture is built so
+ each strategy has a different answer: A wins on raw score, B on robustness
+ (low fold variation, small DAG), and B again once runtime counts, with A
+ demoted for being slow.
+ """
+
+ @staticmethod
+ def _entry(accuracy, std, runtime, n_nodes):
+ dag = SimpleNamespace(
+ nodes=[SimpleNamespace(operation=object()) for _ in range(n_nodes)]
+ )
+ return SimpleNamespace(
+ dag=dag,
+ val_score={"accuracy": accuracy},
+ val_fold_scores={"accuracy": [accuracy - std, accuracy + std]},
+ val_score_stds={"accuracy": std},
+ representation_time=runtime,
+ task_time=0.0,
+ )
+
+ def setUp(self):
+ self.modality = SimpleNamespace(modality_id="m1")
+ self.task = SimpleNamespace(model=SimpleNamespace(name="t1"))
+ self.entries = {
+ "noisy_slow": self._entry(0.90, 0.10, 100.0, 5),
+ "stable_fast": self._entry(0.88, 0.01, 10.0, 2),
+ "weak_fastest": self._entry(0.85, 0.05, 1.0, 1),
+ }
+ self.results = UnimodalResults([self.modality], [self.task], k=3)
+ self.results.results["m1"]["t1"] = list(self.entries.values())
+
+ def _winner(self, strategy, **kwargs):
+ view = self.results.with_selection_strategy(strategy, **kwargs)
+ ranked, _ = view.get_k_best_results(
+ self.modality, self.task, "accuracy", cache_needed=False
+ )
+ return ranked[0]
+
+ def test_performance_takes_the_validation_argmax(self):
+ self.assertIs(self._winner("performance"), self.entries["noisy_slow"])
+
+ def test_robustness_penalises_fold_variation(self):
+ self.assertIs(self._winner("robustness"), self.entries["stable_fast"])
+
+ def test_tradeoff_weighs_runtime_against_accuracy(self):
+ self.assertIs(self._winner("tradeoff"), self.entries["stable_fast"])
+
+ def test_tradeoff_with_all_weight_on_accuracy_matches_performance(self):
+ self.assertIs(
+ self._winner("tradeoff", tradeoff_weights=(1.0, 0.0)),
+ self.entries["noisy_slow"],
+ )
+
+ def test_views_share_the_candidate_pool_and_leave_the_source_alone(self):
+ view = self.results.with_selection_strategy("robustness")
+ self.assertIs(view.results, self.results.results)
+ self.assertIs(view.cache, self.results.cache)
+ self.assertEqual(self.results.selection_strategy, "performance")
+
+ def test_unknown_strategy_is_rejected(self):
+ with self.assertRaises(ValueError):
+ self.results.with_selection_strategy("cheapest")