Contributing
Thank you for your interest in contributing to rl-triton. This guide covers everything you need to add a new kernel, write tests, and get a PR merged.
Setup
You need a CUDA-capable GPU for kernel development. Correctness tests can be written on CPU first (mocking the Triton path), but all timing and integration tests require CUDA.
Repository Layout
src/rl_triton/
├── kernels/ # @triton.jit kernel definitions - no Python logic
│ ├── gae.py
│ ├── vtrace_fused.py
│ └── ...
└── ops/ # PyTorch wrappers - shape checks, dispatch, grid launch
├── gae.py
├── vtrace.py
└── _scan.py # shared chunked scan infrastructure
tests/
├── test_<name>.py # correctness + vectorized baseline per kernel
├── bench_safeguard.py # PR perf gate - one config per kernel, per-kernel floor vs torch.compile
├── bench_release.py # full sweep across all (num_envs, seq_len) configs
└── bench_utils.py # shared timing helpers
The split between kernels/ and ops/ is strict: kernels/ files contain
only @triton.jit functions, ops/ files contain only Python orchestration.
Never put Python control flow in a kernel file, and never put @triton.jit
code in an ops file.
Adding a New Kernel
Follow these five steps in order.
1. Kernel file - src/rl_triton/kernels/<name>_fused.py
One @triton.jit function per file. Use tl.constexpr for BLOCK_SIZE and
any other compile-time constants. Mask out-of-bounds accesses with range
comparisons - never assume seq_len is a power of two at the call site.
import triton
import triton.language as tl
@triton.jit
def my_kernel(
input_ptr, dones_ptr, out_ptr,
seq_len: int,
row_stride: int,
gamma: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
row = tl.program_id(0)
offs = tl.arange(0, BLOCK_SIZE)
mask = offs < seq_len
base = row * row_stride + offs
x = tl.load(input_ptr + base, mask=mask, other=0.0)
# ... kernel body ...
tl.store(out_ptr + base, result, mask=mask)
Add a short docstring explaining the algorithm, scan direction, and any non-obvious indexing decisions.
2. Ops wrapper - src/rl_triton/ops/<name>.py
One public function per file. The wrapper is responsible for:
- Validating shapes, dtypes, and device placement
- Calling
.contiguous()on inputs that the kernel requires to be contiguous - Selecting
BLOCK_SIZEviatriton.next_power_of_2(seq_len) - Dispatching to the fused kernel for
seq_len ≤ 131072, falling back to the chunked scan path for longer sequences
import torch
import triton
from rl_triton.kernels.my_kernel_fused import my_kernel_fused
from rl_triton.ops._scan import _run_scan, _FLAT_MAX_SEQ_LEN
def compute_my_kernel(
inputs: torch.Tensor,
dones: torch.Tensor,
gamma: float,
) -> torch.Tensor:
"""
Docstring: what is computed, recurrence, shape contract, dtype, dispatch.
Args:
inputs: ..., [num_envs, seq_len], float32, CUDA.
dones: Episode termination flags (1.0=done), same shape, float32.
gamma: Discount factor.
Returns:
out: ..., shape [num_envs, seq_len], float32.
"""
assert inputs.is_cuda and dones.is_cuda
assert inputs.dtype == torch.float32
assert inputs.shape == dones.shape
num_envs, seq_len = inputs.shape
inputs = inputs.contiguous()
dones = dones.contiguous()
out = torch.empty_like(inputs)
if seq_len <= _FLAT_MAX_SEQ_LEN:
BLOCK_SIZE = triton.next_power_of_2(seq_len)
my_kernel_fused[(num_envs,)](
inputs, dones, out,
seq_len, inputs.stride(0),
gamma=gamma,
BLOCK_SIZE=BLOCK_SIZE,
)
return out
# Chunked fallback for seq_len > 131072
u = inputs
v = gamma * (1.0 - dones)
return _run_scan(u, v)
3. Export - src/rl_triton/__init__.py
Add the new function to the imports and __all__ list.
4. Tests - tests/test_<name>.py
Every test file must contain two things:
A reference implementation - a plain PyTorch sequential loop with no tricks, used as the correctness ground truth:
def reference_my_kernel(inputs, dones, gamma):
"""Sequential ground truth - never optimise this."""
out = torch.zeros_like(inputs)
carry = torch.zeros(inputs.shape[0], device=inputs.device)
for t in range(inputs.shape[1]):
carry = inputs[:, t] + gamma * (1 - dones[:, t]) * carry
out[:, t] = carry
return out
A vectorized baseline used by the PR performance gate - this is the
strongest torch.compile implementation, not the sequential loop:
@torch.compile
def vectorized_my_kernel(inputs, dones, gamma):
# Vectorized PyTorch equivalent - the bar the Triton kernel must clear
...
Correctness tests with torch.testing.assert_close:
@cuda_only
@pytest.mark.parametrize("num_envs,seq_len", [(1, 16), (64, 512), (4, 131072)])
def test_correctness(num_envs, seq_len):
torch.manual_seed(0)
inputs = torch.randn(num_envs, seq_len, device="cuda")
dones = (torch.rand(num_envs, seq_len, device="cuda") < 0.05).float()
expected = reference_my_kernel(inputs, dones, gamma=0.99)
actual = compute_my_kernel(inputs, dones, gamma=0.99)
torch.testing.assert_close(actual, expected, atol=1e-4, rtol=1e-4)
Test at least: a single environment, a typical batch size, the full fused
range (seq_len = 131072), and episode boundaries (non-zero dones).
5. Performance gate - tests/bench_safeguard.py
Add one entry to bench_safeguard.py for the PR perf gate. It runs a single
representative config (128 envs × 1024 steps) and asserts the Triton kernel
beats torch.compile on the vectorized baseline by at least a per-kernel
floor. Each kernel gets its own _MY_KERNEL_FLOOR constant -- do not reuse
another kernel's floor or a generic placeholder. Calibrate it with
bench_utils._bench_gpu_spread over several trials (5+) across at least 3
independent process runs, then set the floor below the observed minimum with
roughly a 10% margin (wider for noisier kernels) so it survives GPU
power-state variance without flaking:
@cuda_only
@pytest.mark.perf
def test_my_kernel_performance():
# floor 1.6; 3-run min 1.8x, ~10% margin.
_MY_KERNEL_FLOOR = 1.6
inputs = torch.randn(_NUM_ENVS, _SEQ_LEN, device="cuda")
dones = torch.zeros(_NUM_ENVS, _SEQ_LEN, device="cuda")
triton_ms = _bench_gpu(lambda: compute_my_kernel(inputs, dones, gamma=0.99))
pt_compile_ms = _bench_gpu(lambda: vectorized_my_kernel(inputs, dones, gamma=0.99))
speedup = pt_compile_ms / triton_ms
assert speedup >= _MY_KERNEL_FLOOR, (
f"compute_my_kernel speedup {speedup:.2f}× < {_MY_KERNEL_FLOOR}× floor"
)
Running Tests
# Correctness tests (all algorithms, no CUDA required for CPU-only paths)
pytest tests/ -v
# PR performance gate (one config per algorithm, requires CUDA)
pytest -m perf -v
# Full benchmark sweep (all configs, requires CUDA, takes several minutes)
pytest -m slow -v
Numerical Precision
All kernels require float32 and will raise on any other dtype. This is
intentional: the associative scan accumulates over thousands of timesteps and
bfloat16's 7 mantissa bits cause measurable drift at those sequence lengths.
Unlike matrix multiplications - where bf16 errors average out across large
inner products - a sequential scan chains rounding errors multiplicatively,
producing up to 6× relative error on individual advantage estimates at
T=1024, gamma=0.99.
When using torch.autocast, cast inputs back to float32 before calling any
kernel:
with torch.autocast("cuda"):
# ... policy forward pass in bf16 ...
rewards = rewards.float()
values = values.float()
dones = dones.float()
advantages = compute_gae(rewards, values, dones, gamma=0.99, lambda_=0.95)
Done Flag Convention
All kernels use the start-of-episode convention: done[t] = 1 means
timestep t is the first step of a new episode and the carry from the
previous step is zeroed at t.
Most gym-compatible environments (including Gymnasium) use the opposite
end-of-episode convention where done[t] = 1 marks the last step of the
ending episode. Passing these flags directly is silently wrong - shift them
before use:
# gym_dones: end-of-episode convention
kernel_dones = torch.roll(gym_dones, shifts=1, dims=1)
kernel_dones[:, 0] = 0
See NOTES.md for the full explanation.
Common Pitfalls
Non-power-of-two seq_len - always use triton.next_power_of_2(seq_len)
for BLOCK_SIZE and guard out-of-bounds loads with a range mask inside the
kernel.
Non-contiguous inputs - Triton pointer arithmetic assumes row-major
contiguous layout. Call .contiguous() in the wrapper before launching the
kernel. Set RL_TRITON_PERF_WARNINGS=1 at runtime to surface cases where the
.contiguous() call is copying data inside a hot loop. Both environment
variables are read at import time - see
Environment Variables for full
details.
Retrace and discrete actions - compute_retrace requires the full
action-probability vector over all actions to compute \(\mathbb{E}_\pi[Q]\). It
is not applicable to continuous action spaces; use compute_vtrace instead.
Terminated vs truncated episodes - all value-estimating kernels require the caller to handle this distinction, but they do so through different mechanisms:
GAE, V-Trace, lambda returns, discounted returns - these kernels have no
explicit truncateds parameter. The distinction is encoded entirely in
bootstrap_values: pass V(s_T) for a truncated episode (the episode
continues beyond the window, so the next state has real value) and 0 for a
terminated episode (the episode ended, no future value). A mixed batch - some
environments truncated, others terminated - is handled naturally by
constructing bootstrap_values per-environment:
# bootstrap_values[i] = V(s_T) if truncated[i] else 0
bootstrap_values = torch.where(truncated, value_at_boundary, torch.zeros_like(value_at_boundary))
advantages = compute_gae(rewards, values, dones, gamma=0.99, lambda_=0.95,
bootstrap_values=bootstrap_values)
Retrace - because the one-step Q-bootstrap is folded into each TD error
\(\delta[t]\) via next_q_values_all, the distinction must be resolved
inside the kernel rather than at the boundary. Pass terminated (true
episode ends only) as dones and the separate truncated flag as
truncateds. When truncateds=None, every boundary is treated as a
termination - correct for purely episodic data but suboptimal when truncation
is common.
Eligibility traces, episodic prefix sum - forward scans with no value bootstrapping; the terminated/truncated distinction is irrelevant.
Pull Request Checklist
- [ ]
src/rl_triton/kernels/<name>_fused.py- kernel only, no Python logic - [ ]
src/rl_triton/ops/<name>.py- wrapper with shape checks, dispatch, docstring - [ ] Exported from
src/rl_triton/__init__.py - [ ]
tests/test_<name>.py- reference impl, vectorized baseline, correctness tests - [ ] Entry added to
tests/bench_safeguard.py- PR perf gate passes (pytest -m perf) - [ ]
pytest tests/ -vpasses (all correctness tests green) - [ ] Kernel documented in
docs/kernels/