compile=True and the TileLang fast path: engineering notes¶
These notes cover how compile=True and fast=True behave beyond what the README says. They
come from measurements taken while working on #472, #576 and #718, on an RTX 4070 Ti SUPER with
torch 2.11 and tilelang 0.1.14. They are here so the next person does not have to measure them
again.
Backend selection¶
Agent(..., backend="auto") and laya.load(..., backend="auto") opt into the backend
class layer. The default remains eager. Explicit backend= takes precedence over compile
and fast; omitting it preserves both flags' existing behaviour.
eager: the stock PyTorch forward, on any supported device.compile: CUDA-onlytorch.compilewith dynamic shapes andreduce-overheadmode, bucket padding, persistent inductor cache, and warmup at installation. It reuses the same independent-dimension scope ascompile=True, which keeps its existing default mode and CPU support. SetLAYA_COMPILE_WARMUP=0to defer backend warmup andLAYA_INDUCTOR_CACHE_DIRto choose its cache directory (default~/.cache/laya/inductor).tilelang: an adapter around the current fast path, using the agent's bf16 or fp16 dtype.auto: TileLang on CUDA with a supported ModernBERT encoder and dtype when TileLang is installed, otherwise compile on CUDA; eager on other devices. One check comes before the device: a checkpoint on the parallel option layout (Agent.parallel_options, set byoption_layoutin its config) is eager on CUDA too, because the accelerated forwards do not take the parallel masks. Nothing flags the choice —autoanswered with a backend, just not the one the clause above implies — soagent.backendis what says which arm ran, andlaya.backends.auto_policyis the authority on the order of those checks.onnx:laya.load(..., backend="onnx", onnx_path="model.onnx")returns the existingONNXAgent. Withoutonnx_path, it useslaya.onnx.
An unavailable backend emits a RuntimeWarning naming the resolved backend and falls back to
eager. To require a backend, use agent.set_backend("tilelang", strict=True). Switching waits
for active inference; agent.backend reports the active name and agent.backend_object
exposes the installed object. agent.set_backend("compile", warmup=False) defers compilation
until inference, so compilation errors then surface on the request. agent.warmup() remains
available. agent.deaccelerate() removes a backend installed through the class layer.
Routers forward an explicit selection through Router(agent_kwargs={"backend": "auto"}).
They pass no backend argument by default, preserving compatibility with existing Agent-like
constructors. Scoped CPU OOM retries detach the backend and restore it when the model returns
to its original device.
compile=True materialises the attention mask¶
Eager SDPA takes ModernBERT's (rows, 1, L, L) attention mask as a broadcast view. Under the
dynamic shapes that compile=True uses, inductor cannot prove the last dimension is aligned. It
expands the mask to every head and pads it into a real buffer of rows x heads x L x L. In bf16
with 12 heads, that is 0.8 GB at 32 rows x 1024 tokens.
- GPU with headroom. The buffer costs bandwidth, tens of ms per long batch.
- GPU nearly full. The caching allocator thrashes, and the same call can take tens of seconds.
If you compile with long batches on a busy GPU, cap the batch size (predict_batch(...,
batch_size=)) or use fast=True. The TileLang attention reads the packed QKV buffer and masks
by sequence length, so it has no such buffer.
Cold start¶
- First compile. It takes tens of seconds per graph.
compile=Trueneeds two graphs: one for batches and one for a single row, which torch specialises.compile=Truenow callsagent.warmup()during load.compile_warmup=Falserestores lazy compilation, andagent.warmup(shapes=...)remains available manually. Eager and TileLang loads do not warm automatically. These shapes cover common requests, not every possible shape guard. - Warm-up failure. Automatic warm-up is best effort: a failure emits a
RuntimeWarningnaming the error (including the underlying compiler error) and load returns with thetorch.compilewrapper and compile settings intact. For example, Windows without MSVC can load withcompile=Trueeven though warm-up fails. Later requests still use the compiled model and surface compilation failures; Laya does not switch them to eager execution. Explicitagent.warmup()calls also propagate failures, including after a failed automatic warm-up. A successful load therefore does not guarantee that compiled inference is ready. - Across restarts. Inductor's FX-graph cache keeps compiled graphs under
TORCHINDUCTOR_CACHE_DIR. The default is under/tmp, which does not survive a reboot or a container restart. Set it to a persistent directory, or a volume in a container, and a second process loads the graphs instead of compiling them. In #472's measurement that took warm-up from about 120 s to about 50 s. - Laya cache opt-in.
laya.load(..., compile=True, compile_cache=True)sets the process-wideTORCHINDUCTOR_CACHE_DIRonly when absent, to$XDG_CACHE_HOME/laya/torchinductoror~/.cache/laya/torchinductorwhen XDG is unset or not absolute. An existing setting, including one set by an earlier PyTorch compile, wins. The directory is created at load; filesystem errors propagate.compile_cache=False(default), eager, and TileLang loads leave the environment alone. This does not move or delete old caches. Containers still need a persistent home/volume. Cache compatibility and invalidation are managed by PyTorch; a GPU, torch, compiler, model, or input guard change can require compilation again.
Opt-in CUDA graphs¶
agent = laya.load("convaiinnovations/laya", compile=True,
compile_cache=True, compile_mode="reduce-overhead")
compile_mode defaults to "default"; only "default" and "reduce-overhead" are accepted
on the active compiled path. Eager and TileLang loads ignore the compile options. CPU compilation
still works, but CUDA graph recording only applies on CUDA. The CUDA mode requires PyTorch's
torch.compiler.cudagraph_mark_step_begin API; older builds without it raise an explicit error.
Dynamic Dynamo graphs do not imply shape-independent CUDA graphs: new concrete shapes may require warm-up and recording again, without a new Dynamo graph. The two default synthetic warm-up shapes do not pre-record every request shape. Repeated shapes can benefit, but varying shapes can pay extra latency and retain graph pools. PyTorch may skip CUDA graphs for unsupported operations or configurations; setting this mode is not a guarantee of capture.
On compile=True with compile_mode="reduce-overhead", Laya marks each compiled CUDA forward as
a new step, serializes these forwards across its agents, and clones both output tensors outside
the compiled graph before releasing the lock. Agent._infer enters laya._compile.cuda_graph_step
only when _reduce_overhead is set (laya/agent.py:700, read at :1237, entered at :1238), and
nothing outside this branch sets it; the clone of every output is at :1250. The lock
cuda_graph_step takes is a module-level threading.Lock (laya/_compile.py:39, taken at :63),
so every agent in the process shares it: that is what "across its agents" means. Together these
keep retained outputs valid across replays, at the cost of two copies and serialized forward
execution.
backend="compile" is a different route. It still copies its outputs out before releasing its
lock, but it marks no step and locks per agent rather than across agents. CompileBackend.forward
holds an RLock built per instance (laya/backends/compile.py:79, taken at :114) and clones the
sliced outputs inside it (:117); it does not import cuda_graph_step, so it makes no
torch.compiler.cudagraph_mark_step_begin call, and it never raises on a torch build without that
API. Two agents on backend="compile" therefore replay their graphs concurrently, with no step
marked between their forwards. The backend's default mode is "reduce-overhead"
(laya/backends/compile.py:70) and backend="auto" resolves to it on a CUDA machine without
tilelang (laya.backends.auto_policy), so this is the CUDA graph path that runs by default, not
the opt-in one. agent.backend names the route a load took; agent.backend_object._lock is the
specific lock a second backend="compile" agent would not be sharing.
In both routes the lock does not coordinate unrelated application-owned compiled models; callers sharing CUDA graph iterations or using custom streams must manage their own coordination. Disk caches reuse compiled code, not live CUDA graph recordings or their device memory, across processes.
Reproduce cold/restart timings, memory, and cache counters with
benchmarks/bench_compile_defaults.py; see
the recorded measurements.
AOTInductor¶
Shipping a precompiled artifact per checkpoint and GPU architecture
(torch._inductor.aoti_compile_and_package) removes the compile entirely. It works on the current
tree. The blocker these notes recorded was real and is fixed; fast backends has
the export recipe, the check and the measured package/load timings, so this section keeps only what
a reader of these notes needs to not walk into it again.
- Export works.
torch.exportofDecisionModelsucceeds, in about 5 s, with dynamic rows, markers and tokens. Tokens must be declared as a multiple of 16 (16 * Dim(...)); a plain range fails the exporter's ownL % 8alignment guard. This is the same mask alignment as above. - The dtype failure these notes measured is fixed. Packaging from a bf16 copy exported without
autocast used to fail inside the forward with
mat1 and mat2 must have the same dtype:DecisionModel.forwardkeeps the pooled state and the confidence features in fp32 even when the weights are bf16, and with no autocast to reconcile that the first action linear received fp32 against bf16 weights. The forward now casts the concatenated input to the head's weight dtype only when no autocast is active (laya/common.py:653-657), so the AMP and fp32 paths keep their numerics and the decision logits stay fp32.tests/test_decision_model.py::test_action_head_explicit_dtype_and_autocastpins the input each head sees for every combination of fp32/bf16 weights with no-AMP/bf16-AMP/fp16-AMP. - Autocast is still the wrong export context. A program traced under autocast carries dtype
asserts that AOTI trips outside autocast:
Tensor dtype mismatch! Expected: torch.bfloat16, Got: torch.float32. Export a separate evaluation copy in an explicit dtype instead — that is why the cast above is conditional rather than unconditional.
python scripts/check_aoti.py runs the whole route on a tiny randomly initialised encoder with no
network access, and prints SKIP: CUDA and a C++ compiler are required where it cannot package.
TileLang portability: the GPU specialisation is CUDA-only¶
tilelang registers targets for CUDA, HIP, Metal, WebGPU and a C backend. Without AMD or Apple
hardware, the answerable question was whether laya/tl_kernels.py lowers for the CPU at all.
Probed with tilelang.compile(kernel.prim_func, target=...) on Linux x86-64:
| target | result |
|---|---|
"cpu" |
Rejected up front: Target cpu is not supported. tilelang's CPU backend is "c". |
"llvm" |
Cannot find global function target.build.llvm. The wheel ships no LLVM backend. |
"c" |
Lowers to C and runs on CPU tensors, but only for a subset of the language. |
Every Laya kernel fails on "c" as written for the GPU, for one of three reasons:
| kernel | failure on target="c" |
construct |
|---|---|---|
gemm_kernel, gemm_geglu_kernel |
CPU fill only supports local and global buffers, but got dst scope local.fragment |
T.alloc_fragment accumulator |
add_ln_kernel |
CPU reduce only supports local src and local/local.var dst buffers |
T.reduce_sum / T.reduce_max over fragments |
rope_kernel |
Cannot convert type bfloat16 to C type |
bf16 tensors |
attn_kernel |
fails at T.alloc_fragment |
fragments |
The C backend does accept:
- fp32 elementwise loops (
T.Parallel); T.Pipelined, which lowers to a plain loop;T.gemmwith aT.alloc_localaccumulator, which lowers to a scalar triple loop.
That list is now the specification of a CPU lowering, not an argument against one. Each of the five
factories takes a keyword-only cpu=False, and compile_cpu (laya/tl_kernels.py:261-271) sets
cpu=True with dtype="float32" and lowers the same prim_func on target="c" with
TIR_DISABLE_VECTORIZE. The three rejected constructs are the three the selector swaps:
T.alloc_fragment and T.alloc_shared become T.alloc_local, T.Parallel becomes T.grid, and
16-bit activations become fp32. Everything else is shared with the GPU form — tile blocking,
T.Pipelined, T.gemm with GemmWarpPolicy, T.reduce_sum / T.reduce_max — and the only
construct the CPU branch drops is the swizzled shared-memory layout annotation, the if not cpu:
guard on T.annotate_layout inside attn_kernel (laya/tl_kernels.py:218-219), which is a GPU
concern anyway.
What this is not is still what the paragraph before it argued: not a faster way to run on CPU.
compile_cpu's own docstring calls it "a scalar correctness path, not an Agent.accelerate backend",
fast backends claims no CPU speedup and no full-model CPU backend, and
tests/test_fast_cpu.py is the parity check against a reference rather than a benchmark. The
MKL/oneDNN path the stock forward already uses remains the CPU route that is worth running.
The same three constructs are the ones to check first on HIP and Metal: fragments, T.gemm with
GemmWarpPolicy, and bf16/fp16 support.
To reproduce the first row of the second table:
tilelang.compile(K.gemm_kernel(768, 768).prim_func, target="c").