Add DR.Kernel recipe: Triton kernel-generation RL on NPU#49
Conversation
DR.Kernel (arXiv:2602.05885) ported onto verl for Ascend NPU. Includes the KernelGYM distributed reward server, TRLOO advantage estimator (algorithm.adv_estimator=trloo), and fully asynchronous training
There was a problem hiding this comment.
Code Review
This pull request ports the DR.Kernel framework to verl, introducing a multi-turn agent loop, a TRLOO advantage estimator, a multi-turn rejection sampling batch filter, and a distributed KernelGYM evaluation server. The review identified several critical issues: a runtime TypeError when iterating over the BatchFilterConfig dataclass, a blocking psutil call that halts the FastAPI event loop, unhandled exceptions leaving tasks dangling in Redis, and broad exception handling that swallows HTTPExceptions. Additionally, the agent loop's truncation logic is inverted, the two-gate filter contains redundant tensor allocations, and the subprocess pool's error handling is overly broad, leading to unnecessary worker restarts.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if isinstance(omega_cfg, BatchFilterConfig): | ||
| as_dict = {k: getattr(omega_cfg, k) for k in omega_cfg} |
There was a problem hiding this comment.
The BatchFilterConfig class is a standard Python dataclass and is not iterable by default. Attempting to iterate over omega_cfg using for k in omega_cfg will raise a TypeError at runtime. Use dataclasses.asdict to safely convert the dataclass to a dictionary.
| if isinstance(omega_cfg, BatchFilterConfig): | |
| as_dict = {k: getattr(omega_cfg, k) for k in omega_cfg} | |
| if isinstance(omega_cfg, BatchFilterConfig): | |
| from dataclasses import asdict | |
| as_dict = asdict(omega_cfg) |
| async def get_system_health() -> dict[str, Any]: | ||
| try: |
There was a problem hiding this comment.
Calling psutil.cpu_percent(interval=1) is a blocking operation that halts the entire FastAPI event loop for 1 full second. In an asynchronous server, this will block all concurrent request handling and task processing. Use interval=None to retrieve the CPU utilization non-blockingly.
| async def get_system_health() -> dict[str, Any]: | |
| try: | |
| cpu_percent = psutil.cpu_percent(interval=None) |
| try: | ||
| controller = get_workflow_controller(workflow_name or "kernelbench") | ||
| except KeyError as exc: | ||
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc | ||
|
|
There was a problem hiding this comment.
If controller.handle_request raises an exception, the task is never marked as FAILED in Redis because task_mgr.fail_task is not called. This leaves the task permanently dangling in PENDING or PROCESSING status, causing clients polling for results to hang indefinitely. Wrap the execution in a try...except block to ensure the task is failed gracefully.
| try: | |
| controller = get_workflow_controller(workflow_name or "kernelbench") | |
| except KeyError as exc: | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc | |
| try: | |
| result = await controller.handle_request(payload, scheduler) | |
| if isinstance(result, dict): | |
| result.setdefault("task_id", task_id) | |
| await task_mgr.complete_task(task_id, result) | |
| return task_id, result, _result_status(result) | |
| except Exception as e: | |
| error_code = classify_error(str(e), "system") | |
| await task_mgr.fail_task(task_id, str(e), error_code) | |
| raise |
| raise HTTPException( | ||
| status_code=status.HTTP_409_CONFLICT, detail="Device mismatch; please re-register" | ||
| ) | ||
| except Exception: | ||
| pass |
There was a problem hiding this comment.
The broad except Exception: block catches and silently swallows the HTTPException raised for a device mismatch. This prevents the validation error from being propagated to the client, causing mismatched heartbeats to succeed silently. Explicitly catch and re-raise HTTPException first.
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, detail="Device mismatch; please re-register" | |
| ) | |
| except Exception: | |
| pass | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, detail="Device mismatch; please re-register" | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| pass |
| if self.tool_response_truncate_side == "left": | ||
| return text[:n] + "...(truncated)" | ||
| if self.tool_response_truncate_side == "right": | ||
| return "(truncated)..." + text[-n:] |
There was a problem hiding this comment.
The truncation logic for left and right sides is inverted. When tool_response_truncate_side is 'left', it should truncate the left side (keeping the right side of the text), but the current implementation returns the left side (text[:n]) and truncates the right side. Conversely, when it is 'right', it should truncate the right side (keeping the left side), but it returns the right side (text[-n:]).
| if self.tool_response_truncate_side == "left": | |
| return text[:n] + "...(truncated)" | |
| if self.tool_response_truncate_side == "right": | |
| return "(truncated)..." + text[-n:] | |
| if self.tool_response_truncate_side == "left": | |
| return "...(truncated)" + text[-n:] | |
| if self.tool_response_truncate_side == "right": | |
| return text[:n] + "...(truncated)" |
| # Apply mask (set masked positions to 0, which is > threshold) | ||
| masked_diffs = torch.where(response_mask.bool(), logprob_diffs, torch.zeros_like(logprob_diffs)) | ||
|
|
||
| # Check stability: find minimum difference among valid tokens | ||
| min_diffs_per_seq = ( | ||
| torch.where(response_mask.bool(), masked_diffs, torch.full_like(masked_diffs, float("inf"))) | ||
| .min(dim=-1) | ||
| .values | ||
| ) # [bs] |
There was a problem hiding this comment.
The intermediate tensor masked_diffs and its associated torch.zeros_like allocation are redundant. Since min_diffs_per_seq replaces masked positions with inf anyway, you can compute it directly from logprob_diffs using a single torch.where call. This avoids multiple unnecessary tensor allocations and improves efficiency.
# Check stability: find minimum difference among valid tokens
min_diffs_per_seq = (
torch.where(response_mask.bool(), logprob_diffs, float("inf"))
.min(dim=-1)
.values
) # [bs]| is_cuda_error = ( | ||
| "CUDA" in error_type | ||
| or "CUDA" in error_message | ||
| or "cuda" in error_message.lower() | ||
| or error_type in ["RuntimeError", "CudaError"] | ||
| ) | ||
| is_profiler_error = "PROFILER_NO_CUDA_EVENTS" in error_message | ||
|
|
There was a problem hiding this comment.
Treating any RuntimeError as a CUDA error is overly broad. Standard model execution errors (such as shape mismatches or value errors) will raise a RuntimeError and unnecessarily trigger a worker subprocess termination and restart, introducing significant overhead. Use the already imported is_device_fatal_error helper to only restart on actual device-fatal errors.
| is_cuda_error = ( | |
| "CUDA" in error_type | |
| or "CUDA" in error_message | |
| or "cuda" in error_message.lower() | |
| or error_type in ["RuntimeError", "CudaError"] | |
| ) | |
| is_profiler_error = "PROFILER_NO_CUDA_EVENTS" in error_message | |
| is_cuda_error = ( | |
| "CUDA" in error_type | |
| or "CUDA" in error_message | |
| or "cuda" in error_message.lower() | |
| or error_type == "CudaError" | |
| or is_device_fatal_error(error_message) | |
| ) |
- monkey-patch Qwen3MoeSparseMoeBlock.forward to loop over all experts, keeping the non-reentrant gradient-checkpoint saved- tensor count constant - kernelgym utils: use psutil.cpu_percent(interval=None) so the FastAPI /health and /metrics endpoints don't block the event loop for 1s - kernelgym server: mark a task FAILED in Redis when controller.handle_request raises, so the client poller sees a terminal state instead of a dangling task - kernelgym server: log (don't silently swallow) a worker heartbeat device mismatch
[recipe] feat: add Dr. Kernel asynchronous RL recipe for Triton kernel generation on Ascend NPU
What does this PR do?
This PR adds a new
drkernelrecipe that brings the Dr. Kernel approach (RL Done Right for Triton kernel generation) toverl-ascend-recipe, adapted to run on Ascend NPU using fully asynchronous RL training. The model is given a Torch reference implementation and learns to produce optimized kernel code, with execution-based verification.Background
Dr. Kernel targets two failure modes common to RL for kernel generation — reward hacking and lazy optimization — using the KernelGym execution environment for correctness/performance checks, profiling-based rewards, and unbiased multi-turn RL. This recipe ports that workflow onto the verl + Ascend stack.
To address this, the recipe is built on verl's fully asynchronous training path:
This asynchronous design is the key enabler for scaling Dr. Kernel-style RL efficiently on Ascend NPU.
Changes
drkernel/recipe directory containing:README.md— setup, data prep, and async training instructions for Ascend NPUREQUIRED_VERL.txt— pinned/rolling verl version per repo convention