compiler-env / server /compiler_env_environment.py
HideIron's picture
Upload folder using huggingface_hub
d4d59cd verified
Raw History Blame Contribute Delete
25.7 kB
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""
IRis Compiler Optimization Environment Implementation.
An LLM agent selects LLVM optimization passes to minimize execution time
of C programs compiled for RISC-V architecture. The agent builds a sequence
of compiler passes, then compiles and measures performance against standard
optimization level baselines (O0-O3).
"""
import glob
import os
import random
import subprocess
import tempfile
import time
from uuid import uuid4
from openenv.core.env_server.interfaces import Environment
from openenv.core.env_server.types import State
try:
from ..models import CompilerAction, CompilerObservation
except ImportError:
from models import CompilerAction, CompilerObservation
# === PROGRAM CONFIGURATION ===
PROGRAMS_DIR = os.path.join(os.path.dirname(__file__), "..", "training_programs")
EVAL_PROGRAMS = {
"easy": os.path.join(PROGRAMS_DIR, "01_insertion_sort.c"),
"medium": os.path.join(PROGRAMS_DIR, "08_strassen_matrix.c"),
"hard": os.path.join(PROGRAMS_DIR, "120_karatsuba_multiply.c"),
}
ALL_PROGRAMS = sorted(glob.glob(os.path.join(PROGRAMS_DIR, "*.c")))
TRAIN_PROGRAMS = [p for p in ALL_PROGRAMS if p not in EVAL_PROGRAMS.values()]
# === VALID LLVM PASSES (19 high-impact passes) ===
ALL_PASSES = [
# mem
"mem2reg", "sroa",
# cfg
"simplifycfg",
# loop
"loop-simplify", "loop-rotate", "loop-unroll",
"licm", "loop-reduce", "indvars",
# scalar
"gvn", "sccp", "dce", "dse", "early-cse",
"reassociate", "instcombine",
# inline
"inline",
# riscv
"consthoist", "div-rem-pairs",
]
class CompilerEnvironment(Environment):
"""
IRis Compiler Optimization Environment.
The agent selects LLVM optimization passes to minimize execution time
of C programs compiled for RISC-V. Programs are compiled using the full
LLVM pipeline (clang -> opt -> llc -> gcc) and executed via QEMU.
Each add_pass automatically compiles and measures. If the pass improves
execution time it is kept (+0.1 reward). If not, it is auto-removed
(-0.05 reward). Duplicate passes are rejected (-0.1). The agent calls
finalize() when satisfied to receive a final bonus based on baselines.
Episode flow:
1. reset() picks a C program and computes O0-O3 baselines
2. Agent calls add_pass — each pass is auto-compiled and evaluated
3. Only passes that improve execution time are kept in the sequence
4. Agent calls finalize() to end the episode and receive final bonus
Example:
>>> env = CompilerEnvironment()
>>> obs = env.reset()
>>> obs = env.step(CompilerAction(action="list_passes"))
>>> obs = env.step(CompilerAction(action="add_pass:mem2reg"))
>>> obs = env.step(CompilerAction(action="finalize"))
"""
SUPPORTS_CONCURRENT_SESSIONS: bool = True
def __init__(self):
"""Initialize the compiler optimization environment."""
self._state = State(episode_id=str(uuid4()), step_count=0)
self.max_steps = 30
self.action_space = [
"add_pass", "remove_pass", "reorder_sequence", "finalize",
]
self.current_sequence = []
self.valid_passes = ALL_PASSES
self.episode_reward = 0.0
self.done = False
self.eval_programs = EVAL_PROGRAMS
self.train_programs = TRAIN_PROGRAMS
self.current_program = None
self.baseline_O0 = None
self.baseline_O1 = None
self.baseline_O2 = None
self.baseline_O3 = None
self.best_time = None
self.current_time = None
def reset(self, task_id=None, seed=None) -> CompilerObservation:
"""
Reset the environment and start a new episode.
Args:
task_id: Optional task ID ('easy', 'medium', 'hard') for eval.
None selects a random training program.
seed: Optional random seed for reproducible program selection.
Returns:
CompilerObservation with program info and baselines
"""
self._state = State(episode_id=str(uuid4()), step_count=0)
# Seed for reproducibility
if seed is not None:
random.seed(seed)
# Choose program
if task_id is not None:
if task_id in self.eval_programs:
self.current_program = self.eval_programs[task_id]
else:
raise ValueError(
f"Task ID '{task_id}' not found. Valid: {list(self.eval_programs.keys())}"
)
programs_to_try = [self.current_program]
else:
programs_to_try = [random.choice(self.train_programs)]
# Build a retry list of up to 5 different programs
tried = {programs_to_try[0]}
while len(programs_to_try) < 5:
candidate = random.choice(self.train_programs)
if candidate not in tried:
programs_to_try.append(candidate)
tried.add(candidate)
# Try programs until one compiles successfully
last_error = None
for program in programs_to_try:
self.current_program = program
self.current_sequence = []
self.episode_reward = 0.0
self.done = False
self.best_time = None
self.current_time = None
try:
self.baseline_O0 = self._compute_baseline(self.current_program, "O0")
self.baseline_O1 = self._compute_baseline(self.current_program, "O1")
self.baseline_O2 = self._compute_baseline(self.current_program, "O2")
self.baseline_O3 = self._compute_baseline(self.current_program, "O3")
self.best_time = self.baseline_O0
self.current_time = self.baseline_O0
last_error = None
break
except (RuntimeError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
last_error = e
continue
if last_error is not None:
raise RuntimeError(f"All programs failed to compile: {last_error}")
return CompilerObservation(
data={
"program": os.path.basename(self.current_program),
"baselines": {
"O0": self.baseline_O0, "O1": self.baseline_O1,
"O2": self.baseline_O2, "O3": self.baseline_O3,
},
"current_sequence": [],
"best_time": self.best_time,
"steps_remaining": self.max_steps,
},
status="ok",
message=f"Episode started. Program: {os.path.basename(self.current_program)}",
done=False,
reward=0.0,
)
def step(self, action: CompilerAction) -> CompilerObservation: # type: ignore[override]
"""
Execute a step in the environment.
Args:
action: CompilerAction with action string (e.g. "add_pass:mem2reg")
Returns:
CompilerObservation with action-specific data, reward, and done flag
"""
# Check if episode is done
if self.done:
return CompilerObservation(
data={"error": "Episode is done. Call reset() to start a new episode."},
status="error",
message="Episode already finished.",
done=True,
reward=self.episode_reward,
)
# Parse action string
action_str = action.action
action_name = action_str.split(":")[0]
action_arg = action_str.split(":", 1)[1] if ":" in action_str else None
# Validate action
if action_name not in self.action_space:
return CompilerObservation(
data={"error": f"Unknown action '{action_name}'. Valid: {self.action_space}"},
status="error",
message=f"Invalid action: {action_name}",
done=False,
reward=self.episode_reward,
)
# Increment step count
self._state.step_count += 1
# Dispatch
if action_name == "add_pass":
obs_data, status, message = self._add_pass(action_arg)
elif action_name == "remove_pass":
obs_data, status, message = self._remove_pass(action_arg)
elif action_name == "reorder_sequence":
obs_data, status, message = self._reorder_sequence(action_arg)
elif action_name == "finalize":
obs_data, status, message = self._finalize()
else:
raise ValueError(f"Unknown action: {action_name}")
# Check max steps — auto-finalize if out of steps
if self._state.step_count >= self.max_steps and not self.done:
fin_data, fin_status, fin_message = self._finalize()
obs_data.update(fin_data)
message += f" | Auto-finalized: {fin_message}"
return CompilerObservation(
data=obs_data,
status=status,
message=message,
done=self.done,
reward=self.episode_reward,
metadata={"step_count": self._state.step_count},
)
@property
def state(self) -> State:
"""Get current environment state."""
return self._state
def _add_pass(self, pass_name):
"""
Add a pass to the sequence with auto-compile feedback.
1. Reject if missing, invalid, or duplicate
2. Tentatively add and compile the full sequence
3. If execution time improves -> keep (+0.1)
4. If not -> auto-remove (-0.05)
"""
base_data = {
"current_sequence": list(self.current_sequence),
"best_time": self.best_time,
"steps_remaining": self.max_steps - self._state.step_count,
}
if pass_name is None:
self.episode_reward -= 0.1
return (base_data, "error", "No pass name provided. Use format: add_pass:pass_name")
if pass_name not in self.valid_passes:
self.episode_reward -= 0.1
return (base_data, "invalid_pass", f"Pass '{pass_name}' is not valid.")
if pass_name in self.current_sequence:
self.episode_reward -= 0.1
return (
{**base_data, "reason": "duplicate"},
"duplicate",
f"Pass '{pass_name}' is already in sequence. Duplicates not allowed.",
)
# Tentatively add the pass
self.current_sequence.append(pass_name)
# Auto-compile and measure
try:
new_time = self._auto_compile_and_measure()
except (RuntimeError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
self.current_sequence.pop()
self.episode_reward -= 0.1
return (
{**base_data, "error": str(e)},
"compile_failed",
f"Pass '{pass_name}' caused compilation failure. Removed.",
)
prev_best = self.best_time
self.current_time = new_time
if new_time < prev_best:
# Improvement — keep the pass
self.best_time = new_time
self.episode_reward += 0.1
return (
{
"kept": True,
"pass": pass_name,
"execution_time": new_time,
"best_time": self.best_time,
"improvement": True,
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"steps_remaining": self.max_steps - self._state.step_count,
},
"pass_kept",
f"Pass '{pass_name}' KEPT. Time: {new_time:.4f}s (improved from {prev_best:.4f}s).",
)
else:
# No improvement — pass stays, agent must call remove_pass to remove it
self.episode_reward -= 0.05
return (
{
"kept": False,
"pass": pass_name,
"execution_time": new_time,
"best_time": self.best_time,
"improvement": False,
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"steps_remaining": self.max_steps - self._state.step_count,
},
"pass_not_improving",
f"Pass '{pass_name}' did NOT improve time. Time: {new_time:.4f}s vs best: {self.best_time:.4f}s. Use remove_pass('{pass_name}') to remove it.",
)
def _remove_pass(self, pass_name):
"""
Remove a previously kept pass from the sequence.
The agent calls this to undo a pass it no longer wants.
Always gives a negative reward (-0.05) since it means a bad choice was made.
"""
base_data = {
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"best_time": self.best_time,
"steps_remaining": self.max_steps - self._state.step_count,
}
if not pass_name:
self.episode_reward -= 0.1
return (
{**base_data, "reason": "missing_name"},
"error",
"No pass name provided to remove_pass.",
)
if pass_name not in self.current_sequence:
self.episode_reward -= 0.1
return (
{**base_data, "reason": "not_in_sequence"},
"error",
f"Pass '{pass_name}' is not in the current sequence. Current: {self.current_sequence}",
)
# Remove the pass
self.current_sequence.remove(pass_name)
self.episode_reward -= 0.05
# Re-compile to update current_time
if len(self.current_sequence) == 0:
self.current_time = self.baseline_O0
else:
try:
self.current_time = self._auto_compile_and_measure()
except (RuntimeError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
self.current_time = self.baseline_O0
return (
{
"removed": True,
"pass": pass_name,
"current_time": self.current_time,
"best_time": self.best_time,
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"steps_remaining": self.max_steps - self._state.step_count,
},
"pass_removed_by_agent",
f"Pass '{pass_name}' removed from sequence. Reward: -0.05. Current sequence: {self.current_sequence}",
)
def _reorder_sequence(self, new_order_str):
"""
Reorder the current pass sequence. Agent provides a comma-separated
list of the same passes in a new order.
- Must contain exactly the same passes (no additions/removals)
- Auto-compiles and measures
- If time improves -> keep new order (+0.1)
- If not -> revert to old order (-0.05)
"""
base_data = {
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"best_time": self.best_time,
"steps_remaining": self.max_steps - self._state.step_count,
}
if not new_order_str:
self.episode_reward -= 0.1
return (
{**base_data, "reason": "missing_order"},
"error",
"No pass order provided. Provide comma-separated pass names.",
)
new_order = [p.strip() for p in new_order_str.split(",") if p.strip()]
# Validate: must be the same passes, just reordered
if sorted(new_order) != sorted(self.current_sequence):
self.episode_reward -= 0.1
return (
{**base_data, "reason": "invalid_order", "provided": new_order},
"error",
f"Invalid reorder. Must contain exactly the same passes. Current: {self.current_sequence}, Got: {new_order}",
)
# Same order as before — no-op
if new_order == self.current_sequence:
return (
base_data,
"success",
"Sequence unchanged — already in this order.",
)
# Tentatively apply new order
old_sequence = list(self.current_sequence)
self.current_sequence = new_order
try:
new_time = self._auto_compile_and_measure()
except (RuntimeError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
self.current_sequence = old_sequence
self.episode_reward -= 0.1
return (
{**base_data, "reason": "compile_failed"},
"compile_failed",
f"Compilation failed after reorder: {str(e)[:200]}",
)
self.current_time = new_time
if new_time < self.best_time:
old_best = self.best_time
self.best_time = new_time
self.episode_reward += 0.1
return (
{
"reordered": True,
"improved": True,
"execution_time": new_time,
"best_time": self.best_time,
"old_best_time": old_best,
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"steps_remaining": self.max_steps - self._state.step_count,
},
"reorder_improved",
f"Reorder improved time! {new_time:.4f}s (was {old_best:.4f}s). New order: {self.current_sequence}",
)
else:
# Revert
self.current_sequence = old_sequence
self.episode_reward -= 0.05
return (
{
"reordered": False,
"improved": False,
"execution_time": new_time,
"best_time": self.best_time,
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"steps_remaining": self.max_steps - self._state.step_count,
},
"reorder_reverted",
f"Reorder did NOT improve. Reverted. Time: {new_time:.4f}s vs best: {self.best_time:.4f}s",
)
def _get_program_info(self):
"""Return program name and baselines."""
return (
{
"program": os.path.basename(self.current_program),
"baselines": {
"O0": self.baseline_O0, "O1": self.baseline_O1,
"O2": self.baseline_O2, "O3": self.baseline_O3,
},
"steps_remaining": self.max_steps - self._state.step_count,
},
"success",
f"Program: {os.path.basename(self.current_program)}",
)
def _list_passes(self):
"""Return all valid LLVM passes."""
return (
{"valid_passes": self.valid_passes, "total_count": len(self.valid_passes)},
"success",
f"{len(self.valid_passes)} passes available.",
)
def _get_current_sequence(self):
"""Return current pass sequence and best time."""
return (
{
"current_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"best_time": self.best_time,
"steps_remaining": self.max_steps - self._state.step_count,
},
"success",
f"Sequence has {len(self.current_sequence)} passes. Best time: {self.best_time:.4f}s",
)
# ----------------------------------------------
# Compilation & Measurement
# ----------------------------------------------
def _compute_baseline(self, program_path, flag: str) -> float:
"""
Compile a C program with a standard flag (-O0 to -O3),
run on QEMU, and return execution time in seconds.
"""
exe_file = os.path.join(tempfile.mkdtemp(), "baseline.exe")
clang_cmd = [
"clang", "--target=riscv64-unknown-linux-gnu",
"--sysroot=/usr/riscv64-linux-gnu",
"--gcc-toolchain=/usr",
f"-{flag}", program_path, "-o", exe_file, "-static",
]
result = subprocess.run(clang_cmd, capture_output=True, timeout=30)
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace")
raise RuntimeError(
f"Baseline compilation failed for {os.path.basename(program_path)} "
f"with -{flag}: {stderr}"
)
start = time.perf_counter()
subprocess.run(
["qemu-riscv64", exe_file], check=True, capture_output=True, timeout=10,
)
end = time.perf_counter()
return end - start
def _auto_compile_and_measure(self) -> float:
"""
Internal: compile the current pass sequence and return execution time.
Raises RuntimeError / CalledProcessError / TimeoutExpired on failure.
Pipeline: clang -O0 -> opt (agent passes) -> llc -> gcc -> qemu
"""
tmp_dir = tempfile.mkdtemp()
bc_file = os.path.join(tmp_dir, "program.bc")
opt_bc_file = os.path.join(tmp_dir, "program.opt.bc")
asm_file = os.path.join(tmp_dir, "program.s")
exe_file = os.path.join(tmp_dir, "program.exe")
# Step 1: C -> LLVM bitcode (with -O0, no built-in opts)
subprocess.run(
["clang", "--target=riscv64-unknown-linux-gnu",
"--sysroot=/usr/riscv64-linux-gnu",
"--gcc-toolchain=/usr",
"-O0", "-emit-llvm", "-c", self.current_program, "-o", bc_file],
check=True, capture_output=True, timeout=30,
)
# Step 2: Apply agent's passes via opt
if self.current_sequence:
pass_arg = f"-passes={','.join(self.current_sequence)}"
else:
pass_arg = "-passes=default<O0>"
subprocess.run(
["opt", pass_arg, bc_file, "-o", opt_bc_file],
check=True, capture_output=True, timeout=60,
)
# Step 3: LLVM bitcode -> RISC-V assembly
subprocess.run(
["llc", "-march=riscv64", opt_bc_file, "-o", asm_file],
check=True, capture_output=True, timeout=30,
)
# Step 4: Assembly -> executable (static linking)
subprocess.run(
["riscv64-linux-gnu-gcc", asm_file, "-o", exe_file, "-static"],
check=True, capture_output=True, timeout=30,
)
# Step 5: Run on QEMU and measure
start = time.perf_counter()
subprocess.run(
["qemu-riscv64", exe_file],
check=True, capture_output=True, timeout=10,
)
end = time.perf_counter()
return end - start
def _finalize(self):
"""
End the episode and compute final bonus based on best_time vs baselines.
Returns (data, status, message).
"""
self.done = True
# Final bonus based on how best_time compares to baselines
if self.best_time < self.baseline_O3:
bonus = 1.0
tier = "O3"
elif self.best_time < self.baseline_O2:
bonus = 0.5
tier = "O2"
elif self.best_time < self.baseline_O1:
bonus = 0.3
tier = "O1"
elif self.best_time < self.baseline_O0:
bonus = 0.1
tier = "O0"
else:
bonus = -0.5
tier = "none"
self.episode_reward += bonus
return (
{
"final_time": self.best_time,
"baselines": {
"O0": self.baseline_O0, "O1": self.baseline_O1,
"O2": self.baseline_O2, "O3": self.baseline_O3,
},
"pass_sequence": list(self.current_sequence),
"sequence_length": len(self.current_sequence),
"bonus": bonus,
"tier_beaten": tier,
"episode_reward": self.episode_reward,
},
"finalized",
f"Episode complete. Best time: {self.best_time:.4f}s. Beat: {tier}. Bonus: {bonus:+.1f}. Total reward: {self.episode_reward:.2f}",
)
def grade(self, task_id: str) -> float:
"""
Grade the agent's performance on an eval task.
Returns a score between 0.0 and 1.0.
"""
if not self.done:
return 0.001
if self.baseline_O3 is None or self.baseline_O0 is None:
return 0.001
# Max: all 19 unique passes improve (+0.1 each) + beat O3 (+1.0) = 2.9
max_reward = 0.1 * len(ALL_PASSES) + 1.0
# Min: all 30 steps are invalid/duplicate (-0.1 each) + fail finalize (-0.5) = -3.5
min_reward = -0.1 * self.max_steps - 0.5
score = (self.episode_reward - min_reward) / (max_reward - min_reward)
return max(0.001, min(0.999, score))