fusionops-env / src /environment.py
AnishaRoy5555's picture
Update environment.py
25e5dfa unverified
Raw
History Blame Contribute Delete
16.1 kB
"""
FusionOps Environment
OpenEnv-compatible RL environment for ML graph scheduling.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Optional
from .models import (
Action, Config, Graph, ScheduleState, SubgraphEntry,
)
from .cost_model import compute_subgraph_latency, compute_naive_latency
from .validator import validate_action
from .observation import format_observation
@dataclass
class StepResult:
observation: str
reward: float
done: bool
info: dict = field(default_factory=dict)
class FusionOpsEnv:
"""
RL environment for ML graph scheduling.
The agent builds an execution schedule step by step.
"""
def __init__(
self,
graph: Graph,
max_steps: int = 20,
allow_recomputation: bool = True,
):
self.graph = graph
self.max_steps = max_steps
self.allow_recomputation = allow_recomputation
# Precompute naive baseline for grading
self.naive_latency = compute_naive_latency(graph)
self.state: Optional[ScheduleState] = None
self._last_action_result = ""
def reset(self) -> StepResult:
"""Initialize the environment. Returns initial observation."""
self.state = ScheduleState()
self._last_action_result = ""
obs = format_observation(
graph=self.graph,
state=self.state,
last_action_result="",
last_action_error=None,
max_steps=self.max_steps,
naive_latency=self.naive_latency,
)
return StepResult(
observation=obs,
reward=0.0,
done=False,
info={"naive_latency": self.naive_latency},
)
def _classify_error(self, error_msg: str) -> dict:
"""Classify an error message into type + fix hint for the LLM."""
msg = (error_msg or "").lower()
ready_ops = self._compute_ready_ops()
if "oom" in msg or "working set" in msg or "exceeds" in msg:
return {
"type": "Memory Error (working set too large)",
"reason": error_msg,
"fix_hint": "Reduce tile size (e.g., config=[64,64,1]) or use split-K (smaller k for MatMul). Working set must fit in fast memory.",
}
if "dependency" in msg or "needs tensor" in msg or "not scheduled" in msg:
return {
"type": "Dependency Error (op not ready)",
"reason": error_msg,
"fix_hint": f"Choose ops only from READY OPS: {ready_ops}",
}
if "retain" in msg and "not produced" in msg:
return {
"type": "Retention Error (tensor not produced by current subgraph)",
"reason": error_msg,
"fix_hint": "You can only retain tensors that the CURRENT subgraph produces (its outputs).",
}
if "connected" in msg or "subgraph" in msg:
return {
"type": "Connectivity Error (ops do not form connected subgraph)",
"reason": error_msg,
"fix_hint": "Ops in a subgraph must form a connected DAG (one op produces a tensor consumed by another in the same subgraph).",
}
if "parse" in msg or "format" in msg:
return {
"type": "Parse Error (invalid action format)",
"reason": error_msg,
"fix_hint": "Use exact format: SCHEDULE ops=[0,1] config=[128,128,1] retain=[]",
}
if "already scheduled" in msg or "duplicate" in msg:
return {
"type": "Already Scheduled (op already executed)",
"reason": error_msg,
"fix_hint": f"This op was already executed. Choose from READY OPS: {ready_ops}",
}
# Default
return {
"type": "Invalid Action",
"reason": error_msg,
"fix_hint": f"Choose ops from READY OPS: {ready_ops}. Use format: SCHEDULE ops=[op_id] config=[128,128,1] retain=[]",
}
def _compute_ready_ops(self) -> list:
"""Find ops whose predecessors are all scheduled."""
if self.state is None:
return []
ready = []
for op in self.graph.operations:
if op.id in self.state.scheduled_op_ids:
continue
preds = self.graph.op_predecessors.get(op.id, set())
if preds.issubset(self.state.scheduled_op_ids):
ready.append(op.id)
return ready
def _penalty_for_error(self, error_type: str) -> float:
"""Graduated penalties based on error type."""
# Match by prefix to handle the descriptive tags
if error_type.startswith("Parse Error"):
return -0.10
if error_type.startswith("Memory Error"):
return -0.05 # Close to valid, just wrong size
if error_type.startswith("Dependency Error"):
return -0.20 # Clearly wrong order
if error_type.startswith("Retention Error"):
return -0.15
if error_type.startswith("Connectivity Error"):
return -0.15
if error_type.startswith("Already Scheduled"):
return -0.10
return -0.10
def step(self, action: Action) -> StepResult:
"""Execute one scheduling step."""
assert self.state is not None, "Must call reset() before step()"
# Handle recomputation: temporarily allow re-scheduling of ops
state_for_validation = self.state
if self.allow_recomputation:
state_for_validation = self.state.clone()
for oid in action.operation_ids:
state_for_validation.scheduled_op_ids.discard(oid)
# Validate
validation = validate_action(self.graph, action, state_for_validation)
if not validation.is_valid:
self.state.step_count += 1
error_info = self._classify_error(validation.error)
penalty = self._penalty_for_error(error_info["type"])
done = self.state.step_count >= self.max_steps
obs = format_observation(
graph=self.graph,
state=self.state,
last_action_result="",
last_action_error=error_info,
max_steps=self.max_steps,
naive_latency=self.naive_latency,
)
return StepResult(
observation=obs,
reward=penalty,
done=done,
info={"error": validation.error, "error_type": error_info["type"]},
)
# Compute latency
result = compute_subgraph_latency(
self.graph, action, state_for_validation
)
if not result.is_valid:
self.state.step_count += 1
error_info = self._classify_error(result.error)
penalty = self._penalty_for_error(error_info["type"])
done = self.state.step_count >= self.max_steps
obs = format_observation(
graph=self.graph,
state=self.state,
last_action_result="",
last_action_error=error_info,
max_steps=self.max_steps,
naive_latency=self.naive_latency,
)
return StepResult(
observation=obs,
reward=penalty,
done=done,
info={"error": result.error, "error_type": error_info["type"]},
)
# Update state
self._update_state(action, result.total_latency)
# Check if done
all_ops_covered = self._all_ops_covered()
step_limit = self.state.step_count >= self.max_steps
done = all_ops_covered or step_limit
# Compute reward
reward = self._compute_reward(result.total_latency, all_ops_covered, done)
last_result_str = (
f"VALID. Latency={result.total_latency:.1f}, "
f"working_set={result.working_set:.0f}"
)
obs = format_observation(
graph=self.graph,
state=self.state,
last_action_result=last_result_str,
last_action_error=None,
max_steps=self.max_steps,
naive_latency=self.naive_latency,
)
info = {
"latency": result.total_latency,
"working_set": result.working_set,
"tile_latencies": result.tile_latencies,
"total_latency": self.state.total_latency,
"all_ops_covered": all_ops_covered,
}
return StepResult(
observation=obs,
reward=reward,
done=done,
info=info,
)
def get_state(self) -> dict:
"""Return full environment state for serialization."""
assert self.state is not None
return {
"scheduled_op_ids": list(self.state.scheduled_op_ids),
"tensors_in_fast_memory": list(self.state.tensors_in_fast_memory),
"total_latency": self.state.total_latency,
"step_count": self.state.step_count,
"naive_latency": self.naive_latency,
"schedule": [
{
"ops": e.operation_ids,
"config": [e.config.w, e.config.h, e.config.k],
"retain": e.tensors_to_retain,
"latency": e.latency,
}
for e in self.state.schedule_history
],
}
def get_score(self) -> float:
"""
Compute final score in [0, 1].
Score = naive_latency / (naive_latency + agent_latency)
This gives:
- 0.5 if agent matches naive (no improvement)
- approaching 1.0 as agent latency approaches 0
- below 0.5 if agent is worse than naive
Then we remap [0.5, 1.0] -> [0.0, 1.0] so that:
- matching naive = 0.0
- optimal (much better than naive) = approaching 1.0
"""
if self.state is None or self.state.total_latency <= 0:
return 0.0
if not self._all_ops_covered():
# Penalty: not all ops covered
covered = len(self._covered_ops())
total = len(self.graph.operations)
coverage_ratio = covered / total if total > 0 else 0
return coverage_ratio * 0.1 # max 0.1 if incomplete
ratio = self.naive_latency / (self.naive_latency + self.state.total_latency)
# ratio is in (0, 1). naive match gives 0.5. Better gives > 0.5.
# Remap: score = (ratio - 0.5) * 2, clamped to [0, 1]
# But also handle case where agent is worse than naive (ratio < 0.5)
score = max(0.0, (ratio - 0.5) * 2.0)
return min(score, 1.0)
def _update_state(self, action: Action, latency: float):
"""Update schedule state after a valid action."""
# Mark ops as scheduled
for oid in action.operation_ids:
self.state.scheduled_op_ids.add(oid)
# Update fast memory:
# 1. Remove all previously retained tensors that aren't in the new retain list
# (they get evicted when a new subgraph runs)
# Actually, the spec says tensors_to_retain controls what stays AFTER this subgraph.
# Everything else is evicted. So we clear fast memory and only keep retained tensors.
# Clear fast memory
self.state.tensors_in_fast_memory.clear()
# Add retained tensors
for tid in action.tensors_to_retain:
self.state.tensors_in_fast_memory.add(tid)
# Record in history
entry = SubgraphEntry(
operation_ids=list(action.operation_ids),
config=action.config,
tensors_to_retain=list(action.tensors_to_retain),
traversal_order=action.traversal_order,
latency=latency,
)
self.state.schedule_history.append(entry)
# Update counters
self.state.total_latency += latency
self.state.step_count += 1
def _covered_ops(self) -> set[int]:
"""All ops that have been scheduled at least once."""
return set(self.state.scheduled_op_ids)
def _all_ops_covered(self) -> bool:
"""Check if every operation has been scheduled at least once."""
all_op_ids = set(op.id for op in self.graph.operations)
return all_op_ids.issubset(self._covered_ops())
def _compute_reward(
self, step_latency: float, all_ops_covered: bool, done: bool
) -> float:
"""
Compute per-step reward. Higher is better.
Combines efficiency signal with completion incentive.
"""
# Small positive baseline for any valid step (encourages exploration)
valid_step_bonus = 0.02
# Base reward: negative normalized latency (lower latency = higher reward)
avg_naive_per_op = self.naive_latency / len(self.graph.operations)
latency_reward = -step_latency / avg_naive_per_op * 0.1
# Bonus for fusion (multiple ops in one subgraph)
num_ops_in_step = len(
self.state.schedule_history[-1].operation_ids
) if self.state.schedule_history else 1
fusion_bonus = (num_ops_in_step - 1) * 0.05
# Completion bonus
completion_bonus = 0.0
if done and all_ops_covered:
score = self.naive_latency / self.state.total_latency
completion_bonus = score * 0.5
return valid_step_bonus + latency_reward + fusion_bonus + completion_bonus
def parse_action(text: str, graph: Graph) -> Optional[Action]:
"""
Parse an action from LLM text output.
Expected format: SCHEDULE ops=[0,1] config=[128,128,1] retain=[2]
Flexible parsing to handle variations.
"""
text = text.strip()
# Extract operation IDs
ops_match = re.search(r'ops\s*=\s*\[([^\]]*)\]', text)
if not ops_match:
# Try simpler format: just numbers
ops_match = re.search(r'ops\s*=\s*(\d[\d,\s]*)', text)
if not ops_match:
return None
try:
ops_str = ops_match.group(1)
op_ids = [int(x.strip()) for x in ops_str.split(',') if x.strip()]
except (ValueError, IndexError):
return None
# Extract config [w, h, k]
config_match = re.search(r'config\s*=\s*\[([^\]]*)\]', text)
if not config_match:
# Default to native granularity
nw, nh = graph.hardware.native_granularity
config = Config(nw, nh, 1)
else:
try:
parts = [int(x.strip()) for x in config_match.group(1).split(',')]
if len(parts) == 3:
config = Config(parts[0], parts[1], parts[2])
elif len(parts) == 2:
config = Config(parts[0], parts[1], 1)
else:
nw, nh = graph.hardware.native_granularity
config = Config(nw, nh, 1)
except (ValueError, IndexError):
nw, nh = graph.hardware.native_granularity
config = Config(nw, nh, 1)
# Extract retain list
retain_match = re.search(r'retain\s*=\s*\[([^\]]*)\]', text)
if retain_match:
try:
retain_str = retain_match.group(1).strip()
if retain_str:
retain = [int(x.strip()) for x in retain_str.split(',') if x.strip()]
else:
retain = []
except (ValueError, IndexError):
retain = []
else:
retain = []
# Extract traversal order (optional)
trav_match = re.search(r'traversal\s*=\s*\[([^\]]*)\]', text)
traversal = None
if trav_match:
try:
trav_str = trav_match.group(1).strip()
if trav_str:
traversal = [int(x.strip()) for x in trav_str.split(',')]
except (ValueError, IndexError):
traversal = None
return Action(
operation_ids=op_ids,
config=config,
tensors_to_retain=retain,
traversal_order=traversal,
)