kernel-env / train /baseline_eval.py
Shreyansh327's picture
Publish OpenEnv kernel environment and bounded GRPO proof artifacts
e313085 verified
Raw History Blame Contribute Delete
19.5 kB
"""Baseline: frozen model on the held-out KernelBench split, multi-turn, via the kernel_env server.
python baseline_eval.py --env-url http://localhost:8000 --model Qwen/Qwen2.5-Coder-7B-Instruct --backend vllm
Each eval task is one episode of up to `max_turns` submissions; after each submission the model
sees the env feedback (stage, errors, timings) and may refine. Reports compile %, correct %,
mean/median speedup (best correct submission per episode) and a stage histogram.
Also holds the helpers grpo_kernel.py shares (code extraction, feedback message, metrics).
"""
import argparse
import json
import os
import re
import shlex
import statistics
import subprocess
import threading
import time
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from kernel_env import KernelAction, KernelEnv
from kernel_env.server.tasks import load_tasks
STAGE_ORDER = ["format", "compile", "runtime", "timeout", "correctness", "timed"]
_FENCE = re.compile(r"```(\w*)[^\n]*\n(.*?)```", re.DOTALL)
def extract_code(text: str) -> str:
"""Last ```python block, else last fenced block, else the raw text (prompt asks for bare source)."""
blocks = _FENCE.findall(text)
py = [body for lang, body in blocks if lang.lower() in ("python", "py")]
if py or blocks:
return (py or [body for _, body in blocks])[-1].strip()
# unterminated fence (generation cut off): drop the opening line
if "```" in text:
return text.split("```", 1)[1].split("\n", 1)[-1].strip()
return text.strip()
def feedback_message(obs, truncated: bool = False) -> str:
"""User turn shown to the model after a submission."""
msg = f"Submission {obs.turn}/{obs.max_turns}: stage={obs.stage}."
if truncated:
msg = ("Your previous reply hit the token limit and was cut off before the module was complete. "
"Reply with the module source only, no commentary.\n" + msg)
if obs.correct and obs.speedup is not None:
msg += f" Correct, speedup {obs.speedup:.2f}x (ref {obs.t_ref_ms:.3f} ms, yours {obs.t_new_ms:.3f} ms)."
if obs.feedback:
msg += f"\n{obs.feedback}"
if obs.turn < obs.max_turns:
msg += "\nImprove it (fix errors first, then make it faster). Reply with the complete module source only."
return msg
def summarize(episodes: list) -> dict:
"""episodes: dicts with `turns` = [{"stage", "correct", "speedup", "reward"}, ...]."""
n = max(len(episodes), 1)
furthest = [max((STAGE_ORDER.index(t["stage"]) for t in ep["turns"]), default=0) for ep in episodes]
best = [max((t["speedup"] for t in ep["turns"] if t["correct"]), default=None) for ep in episodes]
sp = [s for s in best if s is not None]
roof = [max((t["roofline_frac"] for t in ep["turns"] if t["correct"] and t.get("roofline_frac")), default=None)
for ep in episodes]
roof = [r for r in roof if r is not None]
return {
"episodes": len(episodes),
"compile_pct": 100 * sum(f >= STAGE_ORDER.index("runtime") for f in furthest) / n,
"correct_pct": 100 * len(sp) / n,
"correct_turn1_pct": 100 * sum(bool(ep["turns"] and ep["turns"][0]["correct"]) for ep in episodes) / n,
"faster_than_ref_pct": 100 * sum(s > 1.0 for s in sp) / n,
"mean_speedup": statistics.fmean(sp) if sp else None,
"median_speedup": statistics.median(sp) if sp else None,
"median_roofline_frac": statistics.median(roof) if roof else None,
"stage_hist_all_turns": dict(Counter(t["stage"] for ep in episodes for t in ep["turns"])),
"stage_hist_furthest": dict(Counter(STAGE_ORDER[f] for f in furthest)),
"suspicious": sum(bool(h.get("suspicious")) for ep in episodes for h in ep.get("history", [])),
**_pass_at_k(episodes, best),
}
def _pass_at_k(episodes, best) -> dict:
"""With several samples per task (--samples k): pass@k = share of tasks with any correct episode."""
by_task = {}
for ep, b in zip(episodes, best):
by_task.setdefault(ep.get("task_id"), []).append(b is not None)
k = max((len(v) for v in by_task.values()), default=1)
if k < 2:
return {}
return {"tasks": len(by_task), "samples_per_task": k,
"pass@1_pct": 100 * sum(sum(v) / len(v) for v in by_task.values()) / len(by_task),
f"pass@{k}_pct": 100 * sum(any(v) for v in by_task.values()) / len(by_task)}
def split_task_ids(split: str, levels, kernelbench_dir: str = "") -> list:
"""Same deterministic split as the server (hash of task id), enumerated from a local KernelBench copy."""
tasks = load_tasks(kernelbench_dir, tuple(levels))
return sorted(t for t, task in tasks.items() if task.split == split)
def run_episodes(generate, env_url: str, task_ids: list, split: str = "eval", env_timeout_s: float = 900.0,
on_done=None, on_turn=None) -> list:
"""Turn-synchronous batched episodes: one generate() call per turn for all live episodes.
on_done(ep) fires as each episode finishes (for incremental writes); on_turn(turn, stats) after each turn.
"""
envs = [KernelEnv(base_url=env_url, message_timeout_s=env_timeout_s).sync() for _ in task_ids]
pool = ThreadPoolExecutor(max_workers=max(len(envs), 1))
try:
obs = list(pool.map(lambda e, t: e.reset(task_id=t, split=split).observation, envs, task_ids))
convs = [[{"role": "user", "content": o.problem}] for o in obs]
eps = [{"task_id": t, "turns": [], "completions": []} for t in task_ids]
live = list(range(len(envs)))
turn = 0
while live:
turn += 1
t0 = time.monotonic()
texts = generate([convs[i] for i in live])
gen_s = time.monotonic() - t0
def step(i, code):
t = time.monotonic()
return envs[i].step(KernelAction(code=code)), time.monotonic() - t
results = list(pool.map(step, live, [extract_code(t) for t in texts]))
for i, text, (r, step_s) in zip(live, texts, results):
o = r.observation
eps[i]["completions"].append(text)
eps[i]["turns"].append({"stage": o.stage, "correct": bool(o.correct), "speedup": o.speedup or 0.0,
"reward": r.reward or 0.0, "feedback": o.feedback,
"t_ref_ms": o.t_ref_ms, "t_new_ms": o.t_new_ms, "t_min_ms": o.t_min_ms,
"bound": o.bound, "roofline_frac": o.roofline_frac,
"ref_roofline_frac": o.ref_roofline_frac,
"gen_s": gen_s, "step_s": step_s})
convs[i] += [{"role": "assistant", "content": text}, {"role": "user", "content": feedback_message(o)}]
if r.done:
st = envs[i].state()
eps[i].update(episode_id=st.episode_id, history=st.history)
if on_done:
on_done(eps[i])
if on_turn:
on_turn(turn, {"live": len(live), "gen_s": gen_s,
"stages": dict(Counter(eps[i]["turns"][-1]["stage"] for i in live)),
"max_step_s": max(s for _, s in results)})
live = [i for i, (r, _) in zip(live, results) if not r.done]
return eps
finally:
for e in envs:
e.close()
pool.shutdown()
def run_episodes_pipelined(chat, env_url: str, task_ids: list, split: str = "eval", env_timeout_s: float = 3600.0,
on_done=None, on_step=None) -> list:
"""One independent loop per episode (generate -> step -> ...), all concurrent.
Against a vLLM server, continuous batching overlaps one episode's generation with another's
kernel eval, instead of every episode waiting for the slowest one each turn. An episode that
raises is kept with its error so one failure never loses the run.
"""
def one(task_id):
ep = {"task_id": task_id, "turns": [], "completions": [], "reasoning": []}
env = KernelEnv(base_url=env_url, message_timeout_s=env_timeout_s).sync()
try:
o = env.reset(task_id=task_id, split=split).observation
conv = [{"role": "user", "content": o.problem}]
while True:
t0 = time.monotonic()
text, finish, reasoning, ntok = chat(conv)
gen_s = time.monotonic() - t0
t1 = time.monotonic()
r = env.step(KernelAction(code=extract_code(text)))
step_s = time.monotonic() - t1
o, truncated = r.observation, finish == "length"
ep["completions"].append(text)
ep["reasoning"].append(reasoning)
ep["turns"].append({"stage": o.stage, "correct": bool(o.correct), "speedup": o.speedup or 0.0,
"reward": r.reward or 0.0, "feedback": o.feedback,
"t_ref_ms": o.t_ref_ms, "t_new_ms": o.t_new_ms, "t_min_ms": o.t_min_ms,
"bound": o.bound, "roofline_frac": o.roofline_frac,
"ref_roofline_frac": o.ref_roofline_frac, "truncated": truncated,
"completion_tokens": ntok, "gen_s": gen_s, "step_s": step_s})
if on_step:
on_step(ep)
if r.done:
break
conv += [{"role": "assistant", "content": text}, {"role": "user", "content": feedback_message(o, truncated)}]
st = env.state()
ep.update(episode_id=st.episode_id, history=st.history)
except Exception as e: # keep partial episode
ep["error"] = repr(e)
finally:
env.close()
if on_done:
on_done(ep)
return ep
with ThreadPoolExecutor(max_workers=max(len(task_ids), 1)) as pool:
return list(pool.map(one, task_ids))
def start_vllm_server(model: str, port: int, max_model_len: int, tp: int, spec_mtp: int, extra: str):
"""`vllm serve` subprocess; returns (proc, chat_factory)."""
cmd = ["vllm", "serve", model, "--port", str(port), "--max-model-len", str(max_model_len),
"--tensor-parallel-size", str(tp), *shlex.split(extra)]
if spec_mtp: # model's own multi-token-prediction layer drafts tokens; greedy output is identical to plain decoding
cmd += ["--speculative-config", json.dumps({"method": "mtp", "num_speculative_tokens": spec_mtp})]
print("starting:", shlex.join(cmd), flush=True)
proc = subprocess.Popen(cmd)
deadline = time.monotonic() + 1800
while True:
if proc.poll() is not None:
raise RuntimeError(f"vllm serve exited with {proc.returncode}")
try:
urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
return proc
except OSError:
if time.monotonic() > deadline:
proc.kill()
raise RuntimeError("vllm serve not healthy after 30 min")
time.sleep(3)
def make_chat(base_url: str, model: str, temperature: float, max_new_tokens: int, enable_thinking: bool):
from openai import OpenAI
client = OpenAI(base_url=base_url, api_key="EMPTY", timeout=7200)
def chat(conv):
r = client.chat.completions.create(
model=model, messages=conv, temperature=temperature, max_tokens=max_new_tokens,
extra_body={"chat_template_kwargs": {"enable_thinking": enable_thinking}})
c = r.choices[0]
return (c.message.content or "", c.finish_reason, getattr(c.message, "reasoning_content", None),
r.usage.completion_tokens if r.usage else None)
return chat
def make_generate(backend: str, model: str, temperature: float, max_new_tokens: int, tp: int, batch_size: int,
enable_thinking: bool = False, max_model_len: int = 32768):
# templates without a thinking switch (Qwen2.5) ignore the kwarg
tmpl = {"enable_thinking": enable_thinking}
if backend == "vllm":
from vllm import LLM, SamplingParams
llm = LLM(model=model, tensor_parallel_size=tp, max_model_len=max_model_len)
params = SamplingParams(temperature=temperature, max_tokens=max_new_tokens)
return lambda convs: [o.outputs[0].text for o in llm.chat(convs, params, use_tqdm=False, chat_template_kwargs=tmpl)]
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained(model, padding_side="left")
lm = AutoModelForCausalLM.from_pretrained(model, dtype="auto", device_map="auto")
def generate(convs):
out = []
for k in range(0, len(convs), batch_size):
enc = tok.apply_chat_template(convs[k:k + batch_size], add_generation_prompt=True, padding=True,
return_tensors="pt", return_dict=True, **tmpl).to(lm.device)
with torch.no_grad():
ids = lm.generate(**enc, max_new_tokens=max_new_tokens, do_sample=temperature > 0,
temperature=temperature or None, pad_token_id=tok.pad_token_id or tok.eos_token_id)
out += tok.batch_decode(ids[:, enc["input_ids"].shape[1]:], skip_special_tokens=True)
return out
return generate
def main():
p = argparse.ArgumentParser()
p.add_argument("--env-url", default="http://localhost:8000")
p.add_argument("--model", default="Qwen/Qwen2.5-Coder-7B-Instruct")
p.add_argument("--backend", choices=["vllm-server", "openai", "vllm", "hf"], default="vllm-server",
help="vllm-server: start `vllm serve` and pipeline episodes (fastest); openai: use --base-url; "
"vllm/hf: in-process, turn-synchronous batches")
p.add_argument("--base-url", default="http://127.0.0.1:8001/v1", help="OpenAI-compatible endpoint (openai backend)")
p.add_argument("--port", type=int, default=8001, help="vllm-server port")
p.add_argument("--max-model-len", type=int, default=65536)
p.add_argument("--spec-mtp", type=int, default=0, help="speculative decoding with N MTP draft tokens (Qwen3.5: 2)")
p.add_argument("--vllm-args", default="", help="extra `vllm serve` flags, e.g. '--language-model-only'")
p.add_argument("--tp", type=int, default=1, help="vLLM tensor parallel size")
p.add_argument("--split", default="eval")
p.add_argument("--levels", type=int, nargs="+", default=[1])
p.add_argument("--kernelbench-dir", default=os.environ.get("KERNELBENCH_DIR", ""))
p.add_argument("--task-ids", nargs="*", help="override task list")
p.add_argument("--limit", type=int, default=0)
p.add_argument("--samples", type=int, default=1, help="episodes per task (use temperature > 0) for pass@k")
p.add_argument("--temperature", type=float, default=0.0)
p.add_argument("--max-new-tokens", type=int, default=4096)
p.add_argument("--enable-thinking", action="store_true", help="Qwen3-style <think>; raise --max-new-tokens with it")
p.add_argument("--batch-size", type=int, default=8, help="HF backend generate batch")
p.add_argument("--out", default="baseline_results.jsonl")
p.add_argument("--wandb", metavar="PROJECT", help="log progress, summary and an episode table to W&B")
args = p.parse_args()
task_ids = args.task_ids or split_task_ids(args.split, args.levels, args.kernelbench_dir)
task_ids = task_ids[: args.limit] if args.limit else task_ids
task_ids = [t for t in task_ids for _ in range(args.samples)]
print(f"{len(task_ids)} {args.split} tasks, model {args.model} ({args.backend})")
server = None
if args.backend == "vllm-server":
server = start_vllm_server(args.model, args.port, args.max_model_len, args.tp, args.spec_mtp, args.vllm_args)
args.base_url = f"http://127.0.0.1:{args.port}/v1"
if args.backend in ("vllm-server", "openai"):
chat = make_chat(args.base_url, args.model, args.temperature, args.max_new_tokens, args.enable_thinking)
else:
generate = make_generate(args.backend, args.model, args.temperature, args.max_new_tokens, args.tp,
args.batch_size, args.enable_thinking, args.max_model_len)
run = None
if args.wandb:
import wandb
run = wandb.init(project=args.wandb, config=vars(args), job_type="baseline")
done, lock = [], threading.Lock()
out = open(args.out, "w")
t_start = time.monotonic()
def on_done(ep):
with lock:
done.append(ep)
out.write(json.dumps(ep) + "\n")
out.flush()
best = max((t["speedup"] for t in ep["turns"] if t["correct"]), default=None)
print(f"[{len(done)}/{len(task_ids)} done, {time.monotonic() - t_start:.0f}s] {ep['task_id']}: "
f"{' > '.join(t['stage'] for t in ep['turns'])}" + (f", best {best:.2f}x" if best else "")
+ (f", ERROR {ep['error']}" if ep.get("error") else ""), flush=True)
def on_step(ep):
t = ep["turns"][-1]
tok = t.get("completion_tokens")
rate = f", {tok / t['gen_s']:.0f} tok/s" if tok and t["gen_s"] else ""
print(f" {ep['task_id']} turn {len(ep['turns'])}: {t['stage']}"
+ (f" {t['speedup']:.2f}x" if t["correct"] else "") + (" [TRUNCATED]" if t.get("truncated") else "")
+ f" | gen {t['gen_s']:.0f}s ({tok} tok{rate}) | env step {t['step_s']:.0f}s", flush=True)
if run:
run.log({"gen_s": t["gen_s"], "step_s": t["step_s"], "completion_tokens": tok or 0,
"truncated": int(bool(t.get("truncated"))), "correct": int(t["correct"])})
def on_turn(turn, stats):
s = summarize(done) if done else {}
print(f"[turn {turn}] {stats['live']} live, gen {stats['gen_s']:.0f}s, slowest step {stats['max_step_s']:.0f}s, "
f"stages {stats['stages']}; done {len(done)}, correct {s.get('correct_pct', 0):.0f}%", flush=True)
if run:
run.log({"turn": turn, "live": stats["live"], "gen_s": stats["gen_s"], "max_step_s": stats["max_step_s"],
**{f"stage/{k}": v for k, v in stats["stages"].items()}})
try:
if args.backend in ("vllm-server", "openai"):
eps = run_episodes_pipelined(chat, args.env_url, task_ids, args.split, on_done=on_done, on_step=on_step)
else:
eps = run_episodes(generate, args.env_url, task_ids, args.split, on_done=on_done, on_turn=on_turn)
finally:
out.close()
if server:
server.terminate()
print(f"wall time {time.monotonic() - t_start:.0f}s")
summary = summarize(eps)
print(json.dumps(summary, indent=2))
if run:
run.summary.update({k: v for k, v in summary.items() if not isinstance(v, dict)})
cols = ["task_id", "episode_id", "stages", "best_speedup", "turns"]
rows = [[ep["task_id"], ep.get("episode_id"), " > ".join(t["stage"] for t in ep["turns"]),
max((t["speedup"] for t in ep["turns"] if t["correct"]), default=None), len(ep["turns"])] for ep in eps]
run.log({"episodes": wandb.Table(columns=cols, data=rows)})
run.finish()
if __name__ == "__main__":
main()