Benchmark a Kernel with KCoral¶
Upload Python that runs your kernel, checks the output, and returns a timing
report. For convenience, KCoral provides compile_tirx and benchmark in
kcoral.builtins for your program to use directly. You can also use your own
compilation and measurement code.
Prerequisites¶
Install the GPU worker environment and launch the server. The first example uses TIRx, TVM’s Python-embedded kernel language.
Compile, check and measure¶
KCORAL_URL=http://localhost:8000 python examples/benchmark_kernel.py
"""Compile a TIRx kernel, check its output, and measure GPU activity."""
import os
import numpy as np
from kcoral import Client, Program
SOURCE = r"""
from __future__ import annotations
import torch
from tvm.script import tirx as T
from kcoral.builtins import compile_tirx, benchmark
@T.jit
def add_one(A: T.Buffer((N,), "float32"), B: T.Buffer((N,), "float32"), *, N: T.constexpr):
T.device_entry()
i = T.cta_id([N])
t = T.thread_id([1])
B[i] = A[i] + 1.0
def evaluate(src):
dst = torch.empty_like(src)
compiled = compile_tirx(add_one, {"N": src.numel()})
compiled(src, dst)
torch.testing.assert_close(dst, src + 1.0, rtol=1e-2, atol=1e-3)
return {"check": {"passed": True}, "timing": benchmark(compiled, src, dst)}
"""
def build_program() -> Program:
program = Program()
module = program.upload(id="module", kind="module", source=SOURCE)
evaluate = program.get_function(id="evaluate", module=module, name="evaluate")
values = np.arange(256, dtype=np.float32)
src = program.upload(id="src", kind="tensor", value=values)
report = program.run(id="report", fn=evaluate, args=[src])
program.return_(key="report", value=report)
return program
def main() -> None:
with Client(os.environ.get("KCORAL_URL", "http://localhost:8000")) as client:
result = client.execute(build_program(), timeout_seconds=120)
if not result.completed:
raise SystemExit(f"Benchmark failed: {result.error}")
print(result.results["report"]["check"])
print(result.results["report"]["timing"])
if __name__ == "__main__":
main()
compile_tirx(kernel, bindings) specializes a TIRx kernel and compiles it for
CUDA. bindings supplies constexpr values; a PrimFunc can be compiled without
bindings. Compiled executables are cached by structural hash, up to 32 entries
per process. The helper requires TVM in the worker environment.
benchmark(compiled, src, dst) measures GPU activity with CUPTI. Both functions
use the GPU access of the Python call that invokes them. Importing them into a
module also allows selecting them with get_function and calling them in
separate instructions.
Where to compile¶
On a GPU server¶
Upload your kernel and Python compilation code. For TIRx, import
compile_tirx from kcoral.builtins. CUDA C, CuTeDSL and Triton can use their own
compiler APIs. The example below demonstrates all four languages.
Client.health() reports installed versions.
Compilation can initialize or query CUDA and load GPU modules. Such calls run under the GPU lease, alongside any later kernel execution.
On a CPU server¶
A CPU server executes uploaded Python compilation code and returns library bytes
for a subsequent request to a GPU server. Supply the target architecture from
Client.target() on the GPU server. CPU workers also accept CUDA source uploads;
selecting a name returns the source text and name for your compiler to consume.
Remote Compilation walks through building CUDA C on a CPU server, then uploading the resulting library to a GPU server for execution and measurement.
On the client¶
Build a library locally and upload it with kind="library". Build for the
architecture reported by the server’s Client.target(). The
library protocol describes export and linking requirements,
and the example below demonstrates the build and submission.
Checking correctness¶
Uploaded code can use, for example, torch.testing.assert_close to compare
output against a reference. Return any reports you want to inspect. An assertion
failure stops subsequent instructions; results already selected by return_
remain available.
Measuring GPU activity¶
kcoral.builtins.benchmark measures each call from its first GPU activity to its last,
including kernels, copies and memsets. Host work before and after those endpoints
is excluded; gaps between GPU activities are included. This supports functions
that launch multiple GPU operations.
Pass an optional configuration dict after the callable’s arguments:
from kcoral.builtins import benchmark
timing = benchmark(kernel, src, dst, {"warmup_ms": 25, "repeat_ms": 100, "flush_l2": True})
Those are the defaults. The time budgets determine iteration counts from an
initial estimate. Explicit warmup and repeat counts override their respective
budgets. L2 flushing happens before each call and outside its measured span.
The report contains latency_ms_median, latency_ms_mean, latency_ms_min,
latency_ms_max, warmup, repeat, flush_l2 and activities_stable. A false
activities_stable means calls did not all launch the same GPU activities.
The helper requires PyTorch and cupti-python in the worker environment.
Your Python can also invoke measurement tools such as NCU or Compute Sanitizer
and return their reports with return_file or return_folder. Wait for GPU
subprocesses to finish before returning or releasing the GPU lease.
Running host work without the GPU lease¶
get_function(..., cpu_only=True) declares that calls to the selected function
use no GPU. Before its run, a GPU worker synchronizes outstanding work and
releases the lease, allowing another request to use the GPU. Detected CUDA calls
fail with gpu_access; this is a best-effort check.
The flag applies to the selected function’s run calls. The module’s top-level
Python still executes under the lease when uploaded. To separate host compilation
from GPU loading, return a path or bytes from a CPU-only build function, then
load the compiled result in a function with GPU access.
Request timing¶
The execution response includes these timing fields, also exposed as attributes
on the Python client’s ProgramResult. queue_ms measures waiting for a worker. After assignment, elapsed_ms includes
GPU-lease waiting (lease_wait_ms), holding the lease (lease_held_ms), and other
worker work. Holding a lease reserves the GPU but does not imply continuous GPU
activity. Use the benchmark report for kernel latency.
Compilation examples¶
Compile on the GPU server¶
This client compiles, checks and measures TIRx, CUDA C, CuTeDSL and Triton kernels.
Set KCORAL_URL to the GPU server address.
KCORAL_URL=http://localhost:8000 python examples/remote_compile_client.py
"""Compile, check and time TIRx, CuTeDSL, CUDA C and Triton kernels remotely.
The client uploads kernel and compiler code and needs no local CUDA toolchain.
Compilation retains the GPU lease because it
can call CUDA; the CPU reference explicitly releases it. Timing uses CUPTI GPU activity spans.
"""
from __future__ import annotations
import os
import numpy as np
from kcoral import Client, Program
N = 256
TIRX_KERNEL = r"""
from __future__ import annotations
from tvm.script import tirx as T
@T.jit
def main(
A: T.Buffer((N,), "float32"),
B: T.Buffer((N,), "float32"),
*,
N: T.constexpr,
):
T.device_entry()
i = T.cta_id([N])
t = T.thread_id([1])
B[i] = A[i] + 1.0
"""
CUTEDSL_KERNEL = r"""
import cutlass.cute as cute
@cute.kernel
def add_one_kernel(src: cute.Tensor, dst: cute.Tensor):
tidx, _, _ = cute.arch.thread_idx()
bidx, _, _ = cute.arch.block_idx()
i = bidx * 256 + tidx
if i < cute.size(src):
dst[i] = src[i] + 1.0
@cute.jit
def add_one(src: cute.Tensor, dst: cute.Tensor):
n = cute.size(src)
add_one_kernel(src, dst).launch(grid=((n + 255) // 256, 1, 1), block=(256, 1, 1))
"""
# The selected function is exported through TVM FFI, so it takes TensorView parameters and
# returns void; the inline compiler adds the includes and export macro.
CUDA_KERNEL = r"""
__global__ void add_one_kernel(const float* x, float* y, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) y[i] = x[i] + 1.0f;
}
void add_one(tvm::ffi::TensorView x, tvm::ffi::TensorView y) {
int n = static_cast<int>(x.numel());
add_one_kernel<<<(n + 255) / 256, 256>>>(static_cast<const float*>(x.data_ptr()),
static_cast<float*>(y.data_ptr()), n);
}
"""
TRITON_KERNEL = r"""
import triton
import triton.language as tl
@triton.jit
def add_one(x_ptr, y_ptr, n, BLOCK: tl.constexpr):
offs = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
mask = offs < n
tl.store(y_ptr + offs, tl.load(x_ptr + offs, mask=mask) + 1.0, mask=mask)
"""
# The reference needs no GPU, so it is declared cpu_only when selected: the worker
# hands the GPU over for the call and fails it should it touch CUDA after all.
# assert_close takes its CPU result as is.
CPU_REFERENCE = r"""
import torch
def expected(n):
return torch.arange(n, dtype=torch.float32) + 1.0
"""
# These functions and imports are uploaded to the server.
OPERATIONS = r"""
from kcoral.builtins import benchmark, compile_tirx
def empty(spec):
import torch
return torch.empty(spec["shape"], dtype=getattr(torch, spec["dtype"]), device="cuda")
def assert_close(actual, expected):
import torch
torch.testing.assert_close(actual.cpu(), expected.cpu(), rtol=1e-2, atol=1e-3)
return {"ok": True}
def compile_cuda_binary(source, cfg):
import os
from pathlib import Path
import tvm_ffi.cpp
arch = cfg["arch"].removeprefix("sm_")
suffix = "a" if arch.endswith("a") else ""
digits = arch.removesuffix("a")
key = "TVM_FFI_CUDA_ARCH_LIST"
previous = os.environ.get(key)
os.environ[key] = f"{int(digits[:-1])}.{digits[-1]}{suffix}"
try:
path = tvm_ffi.cpp.build_inline(
name=f"example_{source.name}",
cuda_sources=source.source,
functions=source.name,
backend="cuda",
extra_cuda_cflags=cfg.get("extra_cuda_cflags"),
)
return Path(path).read_bytes()
finally:
if previous is None:
os.environ.pop(key, None)
else:
os.environ[key] = previous
def compile_cuda(source):
import tempfile
from pathlib import Path
import torch
import tvm_ffi
major, minor = torch.cuda.get_device_capability()
arch = f"sm_{major}{minor}" + ("a" if major >= 9 else "")
data = compile_cuda_binary(source, {"arch": arch})
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "kernel.so"
path.write_bytes(data)
module = tvm_ffi.load_module(str(path))
function = module.get_function(source.name)
# Keep the defining module alive with its callable.
def invoke(*args):
_ = module
return function(*args)
return invoke
def compile_cutedsl(kernel, *tensors):
import cutlass.cute as cute
from cutlass.cute.runtime import from_dlpack
return cute.compile(kernel, *(from_dlpack(tensor) for tensor in tensors))
def compile_triton(kernel, *args):
*operands, cfg = args
options = dict(cfg)
grid = tuple(options.pop("grid"))
kernel.warmup(*operands, grid=grid, **options)
return lambda *values: kernel[grid](*values, **options)
"""
def check_against_cpu_reference(program: Program, dst, assert_close) -> None:
module = program.upload(id="reference_module", kind="module", source=CPU_REFERENCE)
reference = program.get_function(id="reference", module=module, name="expected", cpu_only=True)
expected = program.run(id="expected", fn=reference, args=[N])
program.run(id="check", fn=assert_close, args=[dst, expected])
def tirx_program() -> Program:
program = Program()
operations = program.upload(id="operations", kind="module", source=OPERATIONS)
empty = program.get_function(id="empty", module=operations, name="empty")
assert_close = program.get_function(id="assert_close", module=operations, name="assert_close")
benchmark = program.get_function(id="benchmark", module=operations, name="benchmark")
compile_tirx = program.get_function(id="compile_tirx", module=operations, name="compile_tirx")
module = program.upload(id="kernel_module", kind="module", source=TIRX_KERNEL)
kernel = program.get_function(id="kernel", module=module, name="main")
src = program.upload(id="src", kind="tensor", value=np.arange(N, dtype=np.float32))
dst = program.run(id="dst", fn=empty, args=[{"shape": [N], "dtype": "float32"}])
# `bindings` supplies the T.constexpr values the @T.jit kernel specializes on.
compiled = program.run(id="compiled", fn=compile_tirx, args=[kernel, {"N": N}])
program.run(id="invoke", fn=compiled, args=[src, dst])
check_against_cpu_reference(program, dst, assert_close)
timing = program.run(
id="timing",
fn=benchmark,
args=[compiled, src, dst, {"warmup_ms": 25, "repeat_ms": 100}],
)
program.return_(key="timing", value=timing)
program.return_(key="dst", value=dst)
return program
def cutedsl_program() -> Program:
program = Program()
operations = program.upload(id="operations", kind="module", source=OPERATIONS)
empty = program.get_function(id="empty", module=operations, name="empty")
assert_close = program.get_function(id="assert_close", module=operations, name="assert_close")
benchmark = program.get_function(id="benchmark", module=operations, name="benchmark")
compile_cutedsl = program.get_function(
id="compile_cutedsl", module=operations, name="compile_cutedsl"
)
module = program.upload(id="kernel_module", kind="module", source=CUTEDSL_KERNEL)
kernel = program.get_function(id="kernel", module=module, name="add_one")
src = program.upload(id="src", kind="tensor", value=np.arange(N, dtype=np.float32))
dst = program.run(id="dst", fn=empty, args=[{"shape": [N], "dtype": "float32"}])
# CuTeDSL specializes on the tensors, so compiling takes them too; what comes
# back is called with the same plain ones.
compiled = program.run(id="compiled", fn=compile_cutedsl, args=[kernel, src, dst])
program.run(id="invoke", fn=compiled, args=[src, dst])
check_against_cpu_reference(program, dst, assert_close)
timing = program.run(
id="timing",
fn=benchmark,
args=[compiled, src, dst, {"warmup_ms": 25, "repeat_ms": 100}],
)
program.return_(key="timing", value=timing)
program.return_(key="dst", value=dst)
return program
def cuda_program() -> Program:
program = Program()
operations = program.upload(id="operations", kind="module", source=OPERATIONS)
empty = program.get_function(id="empty", module=operations, name="empty")
assert_close = program.get_function(id="assert_close", module=operations, name="assert_close")
benchmark = program.get_function(id="benchmark", module=operations, name="benchmark")
compile_cuda = program.get_function(id="compile_cuda", module=operations, name="compile_cuda")
# `language` makes this a CUDA C source module; selecting a function from it
# creates the source descriptor consumed by compile_cuda in OPERATIONS.
module = program.upload(id="kernel_module", kind="module", source=CUDA_KERNEL, language="cuda")
kernel = program.get_function(id="kernel", module=module, name="add_one")
src = program.upload(id="src", kind="tensor", value=np.arange(N, dtype=np.float32))
dst = program.run(id="dst", fn=empty, args=[{"shape": [N], "dtype": "float32"}])
# Built for the worker GPU's arch, and cached on disk by source and flags, so
# recompiling the same source is much cheaper.
compiled = program.run(id="compiled", fn=compile_cuda, args=[kernel])
program.run(id="invoke", fn=compiled, args=[src, dst])
check_against_cpu_reference(program, dst, assert_close)
timing = program.run(
id="timing",
fn=benchmark,
args=[compiled, src, dst, {"warmup_ms": 25, "repeat_ms": 100}],
)
program.return_(key="timing", value=timing)
program.return_(key="dst", value=dst)
return program
def triton_program() -> Program:
program = Program()
operations = program.upload(id="operations", kind="module", source=OPERATIONS)
empty = program.get_function(id="empty", module=operations, name="empty")
assert_close = program.get_function(id="assert_close", module=operations, name="assert_close")
benchmark = program.get_function(id="benchmark", module=operations, name="benchmark")
compile_triton = program.get_function(
id="compile_triton", module=operations, name="compile_triton"
)
module = program.upload(id="kernel_module", kind="module", source=TRITON_KERNEL)
kernel = program.get_function(id="kernel", module=module, name="add_one")
src = program.upload(id="src", kind="tensor", value=np.arange(N, dtype=np.float32))
dst = program.run(id="dst", fn=empty, args=[{"shape": [N], "dtype": "float32"}])
# A Triton kernel computes its grid at launch, so the grid travels as data
# rather than as a launcher the client writes. Every other `cfg` key is a
# launch keyword — num_warps, num_stages, a constexpr by name.
compiled = program.run(
id="compiled",
fn=compile_triton,
args=[kernel, src, dst, N, 256, {"grid": [1], "num_warps": 4}],
)
program.run(id="invoke", fn=compiled, args=[src, dst, N, 256])
check_against_cpu_reference(program, dst, assert_close)
timing = program.run(
id="timing",
fn=benchmark,
args=[compiled, src, dst, N, 256, {"warmup_ms": 25, "repeat_ms": 100}],
)
program.return_(key="timing", value=timing)
program.return_(key="dst", value=dst)
return program
def main() -> None:
expected = np.arange(N, dtype=np.float32) + 1.0
with Client(os.environ.get("KCORAL_URL", "http://localhost:8000")) as client:
programs = (
("TIRx", tirx_program()),
("CuTeDSL", cutedsl_program()),
("CUDA C", cuda_program()),
("Triton", triton_program()),
)
for language, program in programs:
result = client.execute(program, timeout_seconds=120)
if result.status != "COMPLETED":
print(f"{language}: {result.status} — {result.error}")
continue
# Only lease_held_ms occupied the GPU; the CPU reference ran off it.
print(
f"{language}: {result.elapsed_ms:.0f} ms total, "
f"{result.lease_held_ms:.0f} ms on the GPU, "
f"{result.elapsed_ms - result.lease_held_ms:.0f} ms off it"
)
print(f" kernel {result.results['timing']['latency_ms_median'] * 1e3:.1f} us")
np.testing.assert_allclose(result.results["dst"], expected)
if __name__ == "__main__":
main()
Build and upload a library¶
This client builds a CUDA C library locally for the server’s target architecture, then uploads, checks and measures it. The client machine needs the CUDA toolkit, a host C++ compiler and TVM FFI from the compiler environment.
KCORAL_URL=http://localhost:8000 python examples/library_upload_client.py
"""Build a kernel locally, upload the library, check its output, and time it.
The client owns the compiler flags and builds for the architecture reported by
GET /health. Allocation, comparison and CUPTI measurement run on the server.
The client needs a CUDA toolchain.
"""
from __future__ import annotations
import os
import pathlib
import tempfile
from kcoral import Client, Program
N = 1 << 20
# The export macro is what makes the object loadable: it emits the
# `__tvm_ffi_add_one` symbol the server looks up by function name.
SOURCE = r"""
#include <tvm/ffi/container/tensor.h>
__global__ void add_one_kernel(const float* x, float* y, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) y[i] = x[i] + 1.0f;
}
void add_one(tvm::ffi::TensorView x, tvm::ffi::TensorView y) {
int n = static_cast<int>(x.numel());
add_one_kernel<<<(n + 255) / 256, 256>>>(static_cast<const float*>(x.data_ptr()),
static_cast<float*>(y.data_ptr()), n);
}
TVM_FFI_DLL_EXPORT_TYPED_FUNC(add_one, add_one);
"""
REFERENCE = "def main(a):\n return a + 1.0\n"
# These functions and imports are uploaded to the server.
OPERATIONS = r"""
from kcoral.builtins import benchmark
def empty(spec):
import torch
return torch.empty(spec["shape"], dtype=getattr(torch, spec["dtype"]), device="cuda")
def randn(spec):
import torch
generator = torch.Generator(device="cuda").manual_seed(spec.get("seed", 0))
return torch.randn(
spec["shape"], dtype=getattr(torch, spec["dtype"]), device="cuda", generator=generator
)
def assert_close(actual, expected):
import torch
torch.testing.assert_close(actual.cpu(), expected.cpu(), rtol=1e-2, atol=1e-3)
return {"ok": True}
"""
def build_library(arch: str, directory: str) -> bytes:
"""Compile SOURCE for `arch` — the server's, not this machine's."""
import tvm_ffi.cpp
source_path = os.path.join(directory, "add_one.cu")
pathlib.Path(source_path).write_text(SOURCE)
# Whatever the local toolchain accepts belongs here; this freedom is the
# reason to upload a library rather than let the server build one.
library = tvm_ffi.cpp.build(
"add_one",
cuda_files=source_path,
extra_cuda_cflags=[
f"-gencode=arch=compute_{arch.removeprefix('sm_')},code={arch}",
"-O3",
],
build_directory=directory,
output=os.path.join(directory, "add_one.so"),
)
return pathlib.Path(library).read_bytes()
def build_program(library: bytes) -> Program:
program = Program()
operations = program.upload(id="operations", kind="module", source=OPERATIONS)
empty = program.get_function(id="empty", module=operations, name="empty")
randn = program.get_function(id="randn", module=operations, name="randn")
assert_close = program.get_function(id="assert_close", module=operations, name="assert_close")
benchmark = program.get_function(id="benchmark", module=operations, name="benchmark")
# No compile instruction follows: get_function binds the precompiled callable.
module = program.upload(id="kernel_module", kind="library", value=library)
kernel = program.get_function(id="kernel", module=module, name="add_one")
reference_module = program.upload(id="reference_module", kind="module", source=REFERENCE)
reference = program.get_function(id="reference", module=reference_module, name="main")
src = program.run(id="src", fn=randn, args=[{"shape": [N], "dtype": "float32", "seed": 0}])
dst = program.run(id="dst", fn=empty, args=[{"shape": [N], "dtype": "float32"}])
program.run(id="invoke", fn=kernel, args=[src, dst])
# Compared on the server against a plain-Python reference, so the output
# tensor never travels; assert_close stops the program before timing a
# kernel that is wrong.
expected = program.run(id="expected", fn=reference, args=[src])
check = program.run(id="check", fn=assert_close, args=[dst, expected])
timing = program.run(id="timing", fn=benchmark, args=[kernel, src, dst])
program.return_(key="check", value=check)
program.return_(key="timing", value=timing)
return program
def main() -> None:
with Client(os.environ.get("KCORAL_URL", "http://localhost:8000")) as client:
arch = client.target()["arch"]
with tempfile.TemporaryDirectory() as directory:
library = build_library(arch, directory)
print(f"built {len(library) / 1024:.0f} KiB for {arch}")
result = client.execute(build_program(library), timeout_seconds=120)
if result.status != "COMPLETED":
raise SystemExit(f"{result.status} — {result.error}")
print(
f"{result.elapsed_ms:.0f} ms total, "
f"{result.lease_held_ms:.0f} ms on the GPU, "
f"kernel {result.results['timing']['latency_ms_median'] * 1e3:.1f} us"
)
if __name__ == "__main__":
main()
For a separate compilation server, follow Remote Compilation.