Remote Compilation¶
In large-scale kernel evaluation, compilation can take much longer than kernel execution. Build steps that need only a CPU (central processing unit) can run separately from execution on a GPU (graphics processing unit). Running that host work on expensive GPU servers can increase cost and leave GPUs underutilized. Some compiler APIs query CUDA or load GPU modules during compilation; those steps need GPU access.
A more cost-effective approach is to compile on inexpensive CPU-only machines, download the compiled result, and upload it to a GPU server for execution. KCoral supports this workflow through file upload and download: one request returns a compiled shared library, and the next uploads it for remote execution.
The two requests can use different servers. A single GPU server with the compilation tools installed can also serve both roles.
The client carries the compiled artifact between the requests. Each request
has its own Program and its own handles:
Request |
Destination |
Inputs |
Returned values |
|---|---|---|---|
1. Compile |
Compilation server |
Kernel source and the execution GPU’s target architecture |
Shared-library bytes |
2. Execute |
Execution server |
Those library bytes and the workload |
Correctness and timing reports |
Both programs use POST /execute. The complete client below compiles a CUDA C
add-one kernel and then checks and benchmarks it over 1,048,576 float32
elements. CUDA C is NVIDIA’s GPU extension to C++.
Prepare the two servers¶
Install the client on the machine coordinating the requests. It needs no local compiler or GPU. The two server roles have different requirements:
Role |
Environment |
|---|---|
CPU compiler |
The compiler environment, the CUDA toolkit with |
GPU executor |
The GPU worker environment, including PyTorch, TVM FFI and CUPTI for benchmarking |
nvcc compiles CUDA source. TVM FFI is a foreign-function interface for calling
compiled code and exchanging tensors. CUPTI is NVIDIA’s CUDA Profiling Tools
Interface, used to collect GPU activity timestamps. This example uploads Python
that builds CUDA C through TVM FFI.
On the compilation host, start a CPU server:
kcoral server --device cpu --num-workers 8 --host 0.0.0.0 --port 8000
On the execution host, start a GPU server:
kcoral server --device gpu --gpus 0 --workers-per-gpu 8 --host 0.0.0.0 --port 8001
The hosts may be different machines. See Launch the server for binding and worker configuration.
Run the complete client¶
From the repository checkout, replace these addresses with your servers:
KCORAL_CPU_URL=http://compiler.example.com:8000 \
KCORAL_GPU_URL=http://gpu.example.com:8001 \
python examples/cpu_compile_gpu_execute.py
Download the complete client.
The downloaded file can be run directly with the same environment variables.
If both servers run on the client machine, the defaults are
http://localhost:8000 for compilation and http://localhost:8001 for execution.
KCORAL_CPU_URL selects the compilation endpoint. To use one GPU server for
both roles, install the compilation tools there and set both URL variables to
that server’s address. The client still submits two independent programs.
Read the execution target¶
Before compiling, ask the execution server which GPU architecture the library must support:
arch = gpu_client.target()["arch"]
target() reads GET /health. The CPU server has no GPU target of its own;
pass the execution server’s arch explicitly to the uploaded compile_cuda_binary
function.
For example, a server might report sm_100a; use its actual reported value.
Request 1: compile and return the library¶
The source defines a GPU kernel and a host function, add_one, which launches
it. The host function uses TVM FFI’s TensorView parameters to access tensors.
The uploaded compiler uses tvm_ffi.cpp.build_inline to supply the required
includes and export wrapper.
CUDA_SOURCE = 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);
}
"""
The example’s OPERATIONS string defines the Python compiler and execution
helpers uploaded by each program:
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 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
"""
The compilation program uploads that Python and the CUDA source, selects
compile_cuda_binary with get_function(..., cpu_only=True), selects the CUDA
source name add_one, compiles for arch, and explicitly returns the library:
def compile_program(arch: str) -> Program:
program = Program()
operations = program.upload(id="operations", kind="module", source=OPERATIONS)
compile_cuda_binary = program.get_function(
id="compile_cuda_binary", module=operations, name="compile_cuda_binary", cpu_only=True
)
module = program.upload(
id="source",
kind="module",
language="cuda",
source=CUDA_SOURCE,
)
source = program.get_function(id="add_one_source", module=module, name="add_one")
library = program.run(
id="library",
fn=compile_cuda_binary,
args=[source, {"arch": arch, "extra_cuda_cflags": ["-O3"]}],
)
program.return_(key="library", value=library)
return program
The uploaded compile_cuda_binary function builds a shared library without loading it or
launching the kernel. return_(key="library", ...) selects the bytes for the
response. After cpu_client.execute() succeeds,
compiled.results["library"] is a Python bytes object containing the shared
library, equivalent to the contents of a .so file.
The compilation request creates no GPU tensors. It can finish and release its
worker before the execution request begins. When using a GPU server for this
request, cpu_only=True releases its lease during the compiler function’s run.
The Python module upload still executes its top-level code under the lease.
Request 2: upload the library and execute¶
The next program receives those bytes as its library argument. It uploads
them with kind="library", selects the exported function, creates input and
output tensors, and runs the kernel:
def benchmark_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")
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])
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, {"warmup_ms": 25, "repeat_ms": 100}],
)
program.return_(key="check", value=check)
program.return_(key="timing", value=timing)
return program
Loading the library recreates a module and function handle on the execution
server. This program contains no compilation operation. It compares the kernel
output against an uploaded Python reference on that server, then benchmarks
only if the uploaded assert_close function passes. That function uses
torch.testing.assert_close; measurement uses the importable
kcoral.builtins.benchmark helper.
The response returns check and timing. Tensor data stays on the execution
server; the client receives the correctness report and timing statistics.
Submit the requests in order¶
The client connects the two programs by passing the returned bytes to the second builder. It checks that compilation completed and returned binary data before submitting anything to the execution server:
def main() -> None:
cpu_url = os.environ.get("KCORAL_CPU_URL", "http://localhost:8000")
gpu_url = os.environ.get("KCORAL_GPU_URL", "http://localhost:8001")
with Client(cpu_url) as cpu_client, Client(gpu_url) as gpu_client:
arch = gpu_client.target()["arch"]
compiled = cpu_client.execute(compile_program(arch), timeout_seconds=120)
if not compiled.completed:
raise SystemExit(f"compile failed: {compiled.error}")
library = compiled.results["library"]
if not isinstance(library, bytes):
raise SystemExit("compile server returned a non-binary library")
result = gpu_client.execute(benchmark_program(library), timeout_seconds=120)
if not result.completed:
raise SystemExit(f"benchmark failed: {result.error}")
timing = result.results["timing"]
print(f"compiled {len(library) / 1024:.0f} KiB for {arch}")
print(f"CPU request held a GPU lease for {compiled.lease_held_ms:.0f} ms")
print(
f"GPU request held its lease for {result.lease_held_ms:.0f} ms; "
f"kernel median {timing['latency_ms_median'] * 1e3:.1f} us"
)
There are two Client.execute() calls. Each is an independent program
submission; automatic blob-cache negotiation may add HTTP attempts to a
submission. The second call uploads the library through the normal client
cache protocol.
On success, the script prints the library size in KiB (1024 bytes), the target
architecture, each request’s GPU lease time, and the kernel’s median duration
in microseconds. A lease gives a worker exclusive access to its GPU; the CPU
compilation request holds no GPU lease. The execution request’s
result.results["check"] contains the correctness report and
result.results["timing"] contains the measurement statistics. See
Benchmark a Kernel with KCoral to interpret them.
Reuse the artifact and handle failures¶
The returned library bytes can be saved to a file and supplied to another execution request later. The execution server may be a different instance; no handle or session from the compilation server is needed. Each execution still uploads the bytes and selects its own function handle.
The library must match the execution host’s platform, GPU architecture and runtime dependencies. Keep the compiler and executor’s TVM FFI and CUDA components compatible. See the library upload protocol for loading and export requirements.
A failed compilation stops the client before the second submission. A failed
execution stops it before reporting a successful benchmark. Inspect each
result’s error and request_id to identify which server and instruction
failed. Request or transport exceptions are distinct from a FAILED result;
the failure guide
explains how to handle them.