Shreyansh327's picture
Publish OpenEnv kernel environment and bounded GRPO proof artifacts
e313085 verified
Raw History Blame Contribute Delete
14.9 kB
"""Evaluate one candidate kernel in an isolated process (GPU only).
Usage: python -I worker.py request.json result.json
The parent (evaluator.py) runs this with a timeout and a single visible GPU; crashes, hangs and
CUDA context corruption die with the process. Result is written to a file, not stdout, so
candidate prints cannot forge it. Candidate source has already passed static_checks.
GPU sharing: several workers may run on one GPU. Each holds a shared flock on the GPU's lock file
while it compiles and checks correctness, and an exclusive one while benchmarking, so no other
eval's kernels run on that GPU during a timing.
"""
import contextlib
import fcntl
import json
import math
import sys
import time
import traceback
PHASES: dict = {} # seconds per phase, returned with the result: where an eval's time went
SOFTMAX = "level1/23_Softmax"
FROBENIUS = "level1/37_FrobeniusNorm_"
NORMALIZED_TASKS = {SOFTMAX, FROBENIUS}
ACTIVATION_TASKS = {'level1/19_ReLU', 'level1/20_LeakyReLU', 'level1/31_ELU',
'level1/29_Softplus', 'level1/88_MinGPTNewGelu'}
class HostOperationError(RuntimeError):
pass
def kernel_only_mode(device_type="cuda"):
"""Catch tensor operators that AST checks cannot distinguish from scalar/shape arithmetic.
This checks PyTorch dispatch inside forward, not arbitrary native code or graphs built in __init__.
Allocation, layout and device-local copies stay allowed. CPU calibration math in Triton's
autotuner stays outside the guarded device; candidate inputs cannot be copied there for compute.
"""
from torch.utils._python_dispatch import TorchDispatchMode
from torch.utils._pytree import tree_leaves
import torch
allowed = {
"empty", "empty_like", "empty_strided", "new_empty", "new_empty_strided",
"zeros", "zeros_like", "new_zeros", "ones", "ones_like", "full", "full_like", "new_full",
"arange", "rand", "randn", "randint", "rand_like", "randn_like",
"view", "_unsafe_view", "reshape", "as_strided", "slice", "select", "expand",
"transpose", "t", "permute", "squeeze", "unsqueeze", "detach", "alias",
"clone", "contiguous", "to", "_to_copy", "copy_", "zero_", "fill_", "lift_fresh",
}
class KernelOnlyMode(TorchDispatchMode):
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
kwargs = kwargs or {}
namespace, name = func._schema.name.split("::", 1)
touches_device = any(isinstance(x, torch.Tensor) and x.device.type == device_type
for x in tree_leaves((args, kwargs)))
if namespace == "aten" and touches_device:
off_device = (name == "_to_copy" and kwargs.get("device") is not None
and torch.device(kwargs["device"]).type != device_type)
off_device |= name == "copy_" and args[0].device.type != device_type
if name not in allowed or off_device:
raise HostOperationError(f"PyTorch operation '{func}' in forward; use a custom kernel")
return func(*args, **kwargs)
return KernelOnlyMode()
def adversarial_inputs(torch, inputs, task_id: str, trial: int):
"""Keep task shapes; vary normalization inputs as well as RNG seeds. Never used for timing."""
if task_id in ACTIVATION_TASKS:
if trial == 1:
inputs[0].mul_(64).sub_(32)
elif trial == 2:
inputs[0].zero_()
return inputs
if task_id not in NORMALIZED_TASKS:
return inputs
x = inputs[0]
if trial == 1:
x.fill_(0 if task_id == SOFTMAX else 1)
elif trial == 2:
if task_id == SOFTMAX:
x.zero_()
rows = torch.arange(x.shape[0], device=x.device)
x[rows, (rows * 104729) % x.shape[1]] = math.log(x.shape[1]) + 8
else:
x.mul_(2).sub_(1)
scales = torch.arange(1, x.shape[0] + 1, device=x.device)
x.mul_(scales.reshape((-1,) + (1,) * (x.ndim - 1)))
elif trial == 3 and task_id == FROBENIUS:
x.zero_()
x[(0,) * x.ndim] = 3
x[(-1,) * x.ndim] = -4
return inputs
def numerical_error(torch, reference, actual, task_id: str, atol: float, rtol: float):
"""Return feedback on failure. Other tasks retain their configured tolerance (including TF32)."""
if task_id in NORMALIZED_TASKS:
if not torch.isfinite(reference).all() or not torch.isfinite(actual).all():
return "Normalization output must be finite"
# Absolute 0.01 dwarfs these outputs. Scale its floor to the reference instead,
# retaining the configured relative tolerance and a small allowance near zero.
atol = min(atol, reference.abs().max().item() * 1e-5)
if task_id == SOFTMAX:
if (actual < 0).any() or not torch.allclose(
actual.sum(dim=1, dtype=torch.float64),
torch.ones(actual.shape[0], device=actual.device, dtype=torch.float64),
atol=1e-4, rtol=rtol,
):
return "Softmax probabilities must be nonnegative and each row must sum to 1"
elif not math.isclose(torch.linalg.vector_norm(actual).item(), 1.0, abs_tol=1e-4, rel_tol=rtol):
return "Frobenius normalization must have global L2 norm 1 (not one norm per slice)"
if not torch.allclose(actual, reference, atol=atol, rtol=rtol, equal_nan=task_id not in NORMALIZED_TASKS):
bad = (~torch.isclose(actual, reference, atol=atol, rtol=rtol,
equal_nan=task_id not in NORMALIZED_TASKS)).sum().item()
return f"{bad}/{actual.numel()} elements wrong (atol={atol:.3g}, rtol={rtol})"
return None
def _tb(limit: int = 2000) -> str:
return traceback.format_exc()[-limit:]
def _stage_for(e: BaseException) -> str:
return "compile" if "CompilationError" in type(e).__name__ else "runtime"
class Phase:
def __init__(self, name):
self.name = name
def __enter__(self):
self.t = time.monotonic()
def __exit__(self, *exc):
PHASES[self.name] = PHASES.get(self.name, 0.0) + time.monotonic() - self.t
def evaluate(req: dict) -> dict:
with Phase("import"):
import torch
from triton.testing import do_bench # bound before candidate import
dev = torch.device("cuda")
seeds = req["seeds"]
lock = open(req["gpu_lock"], "a") if req.get("gpu_lock") else None
@contextlib.contextmanager
def exclusive():
if lock is None:
yield
return
# release before re-locking: an in-place shared->exclusive upgrade deadlocks when two workers
# both hold the shared lock (seen under gVisor on Modal)
with Phase("lock_wait"):
fcntl.flock(lock, fcntl.LOCK_UN)
fcntl.flock(lock, fcntl.LOCK_EX) # waits for other evals to leave the GPU
try:
yield
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
fcntl.flock(lock, base)
# exclusive=True: retry of a run that hit OOM while sharing the GPU; hold the GPU alone throughout
base = fcntl.LOCK_EX if req.get("exclusive") else fcntl.LOCK_SH
if lock is not None:
with Phase("lock_wait"):
fcntl.flock(lock, base)
def to_dev(x):
return x.to(dev) if isinstance(x, torch.Tensor) else x
def bench(model, inputs):
with Phase("bench"), exclusive(), torch.no_grad():
return do_bench(lambda: model(*inputs), warmup=req["warmup_ms"], rep=req["rep_ms"], return_mode="median")
ref_ns: dict = {}
exec(compile(req["ref_src"], "reference.py", "exec"), ref_ns)
with Phase("reference_init"), torch.device(dev): # KernelBench builds tensors on CPU; build them on the GPU
init_inputs = [to_dev(x) for x in ref_ns["get_init_inputs"]()]
torch.manual_seed(seeds[0])
ref = ref_ns["Model"](*init_inputs).to(dev).eval()
def make_inputs(seed):
with Phase("inputs"), torch.device(dev):
torch.manual_seed(seed)
return [to_dev(x) for x in ref_ns["get_inputs"]()]
# Reference timing + roofline counts are cached per task by the evaluator (ref_cache) after the first run.
# Otherwise time the reference before candidate code is imported, so import-time side effects can't slow it.
out = dict(req["ref_cache"]) if req.get("ref_cache") else _measure_reference(torch, ref, make_inputs(seeds[0]), bench)
out["gpu"] = torch.cuda.get_device_name()
return _evaluate_candidate(req, torch, dev, ref, init_inputs, make_inputs, bench, out)
def _measure_reference(torch, ref, inputs, bench) -> dict:
out = {"t_ref_ms": bench(ref, inputs)}
# roofline inputs: counted FLOPs (matmul/conv/attention) and compulsory bytes (inputs + params + outputs)
try:
from torch.utils.flop_counter import FlopCounterMode
with torch.no_grad(), FlopCounterMode(display=False) as fc:
ref_out = ref(*inputs)
outs = ref_out if isinstance(ref_out, (tuple, list)) else [ref_out]
read = [x for x in inputs if isinstance(x, torch.Tensor)] + list(ref.parameters()) + list(ref.buffers())
out["flops"] = fc.get_total_flops()
out["bytes"] = sum(t.numel() * t.element_size() for t in read + [o for o in outs if isinstance(o, torch.Tensor)])
out["dtype"] = next((str(x.dtype) for x in read if x.is_floating_point()), None)
except Exception:
pass # roofline is optional; never fail an evaluation over it
return out
def _evaluate_candidate(req, torch, dev, ref, init_inputs, make_inputs, bench, out) -> dict:
atol, rtol, seeds = req["atol"], req["rtol"], req["seeds"]
cand_ns: dict = {"__name__": "candidate"}
try:
# compile from a real file: @triton.jit reads kernel source via inspect.getsource
with Phase("import_candidate"), open(req["code_path"]) as f:
exec(compile(f.read(), req["code_path"], "exec"), cand_ns)
except BaseException:
return {**out, "stage": "compile", "feedback": _tb()}
try:
torch.manual_seed(seeds[0]) # same seed => same parameter init as reference
with torch.device(dev):
new = cand_ns["ModelNew"](*init_inputs).to(dev).eval()
except BaseException as e:
return {**out, "stage": _stage_for(e), "feedback": _tb()}
guard = kernel_only_mode(dev.type)
def candidate(*inputs):
with guard:
return new(*inputs)
max_err = 0.0
call_s = [] # wall time of each candidate call in the correctness trials (trial 0 includes JIT)
for k, seed in enumerate(seeds):
inputs = adversarial_inputs(torch, make_inputs(seed), req.get("task_id", ""), k)
# the first call includes Triton JIT / CUDA compile time
with Phase("correctness" if k else "first_call_incl_jit"), torch.no_grad():
ref_out = ref(*inputs)
sums = [torch.sum(x, dtype=torch.float64).item() for x in inputs if isinstance(x, torch.Tensor)]
try:
t0 = time.monotonic()
new_out = candidate(*inputs)
torch.cuda.synchronize()
call_s.append(time.monotonic() - t0)
except HostOperationError as e:
return {**out, "stage": "format", "correct": False, "feedback": str(e)}
except BaseException as e:
return {**out, "stage": _stage_for(e), "feedback": _tb()}
refs = list(ref_out) if isinstance(ref_out, (tuple, list)) else [ref_out]
news = list(new_out) if isinstance(new_out, (tuple, list)) else [new_out]
if len(refs) != len(news):
return {**out, "stage": "correctness", "correct": False,
"feedback": f"Expected {len(refs)} outputs, got {len(news)}"}
in_ptrs = {x.untyped_storage().data_ptr() for x in inputs if isinstance(x, torch.Tensor)}
for i, (r, n) in enumerate(zip(refs, news)):
if not isinstance(n, torch.Tensor) or n.shape != r.shape or n.dtype != r.dtype or n.device != r.device:
got = (tuple(n.shape), n.dtype, n.device) if isinstance(n, torch.Tensor) else type(n).__name__
return {**out, "stage": "correctness", "correct": False,
"feedback": f"Output {i}: expected {(tuple(r.shape), r.dtype, r.device)}, got {got}"}
if n.untyped_storage().data_ptr() in in_ptrs:
return {**out, "stage": "correctness", "correct": False,
"feedback": f"Output {i} aliases an input tensor; allocate a new output"}
err = (n.float() - r.float()).abs().nan_to_num(float("inf")).max().item() if n.numel() else 0.0
max_err = max(max_err, err)
error = numerical_error(torch, r, n, req.get("task_id", ""), atol, rtol)
if error:
return {**out, "stage": "correctness", "correct": False, "max_abs_err": err,
"feedback": f"Output {i} mismatch on trial {k}: {error}; max_abs_err={err:.3g}"}
if sums != [torch.sum(x, dtype=torch.float64).item() for x in inputs if isinstance(x, torch.Tensor)]:
return {**out, "stage": "correctness", "correct": False,
"feedback": "Inputs were modified in place; kernels must not write to inputs"}
del inputs, ref_out, new_out, refs, news
t_ref = out["t_ref_ms"]
# A kernel far slower than the reference would hold the GPU's exclusive lock for minutes in do_bench while
# every other eval waits; its reward is ~0.1 regardless, so use the trial timing instead.
trial_ms = 1000 * min(call_s[1:] or call_s)
if trial_ms > max(req.get("slow_factor", 50) * t_ref, 1000.0):
t_new, note = trial_ms, " (very slow: timed from the correctness trials, not benchmarked)"
else:
inputs = make_inputs(seeds[0])
try:
t_new, note = bench(candidate, inputs), ""
except HostOperationError as e:
return {**out, "stage": "format", "correct": False, "feedback": str(e)}
speedup = t_ref / t_new
return {**out, "stage": "timed", "correct": True, "max_abs_err": max_err, "t_new_ms": t_new,
"speedup": speedup,
"feedback": f"Correct on {len(seeds)} trials. Reference {t_ref:.4f} ms, yours {t_new:.4f} ms{note}, "
f"speedup {speedup:.2f}x on {out['gpu']}."}
def main(req_path: str, out_path: str) -> None:
with open(req_path) as f:
req = json.load(f)
try:
res = evaluate(req)
except BaseException:
res = {"stage": "runtime", "feedback": _tb()}
res["phases"] = {k: round(v, 3) for k, v in PHASES.items()}
with open(out_path, "w") as f:
json.dump(res, f)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])