mirror of
https://github.com/tile-ai/tilelang.git
synced 2026-10-20 06:30:07 +08:00
328 lines
11 KiB
Python
328 lines
11 KiB
Python
# Test Plan: Disk Cache Verification using PostProc Callback
|
|
#
|
|
# Purpose: Reliably test disk cache in CI by using postproc callbacks to detect
|
|
# whether compilation actually happened or cache was used.
|
|
#
|
|
# Strategy:
|
|
# - postproc is ONLY called during codegen (cache miss)
|
|
# - postproc is NOT called when loading from cache (cache hit)
|
|
# - Use a counter in postproc to distinguish these cases
|
|
#
|
|
# CI Safety:
|
|
# - Use isolated cache/tmp directories per test (pytest tmp_path)
|
|
# - Use unique kernel identifiers (UUID + global_symbol) to avoid collisions
|
|
# - Clear memory cache between passes to force disk I/O
|
|
# - os.replace() requires source and dest on same filesystem (atomic rename)
|
|
#
|
|
# Technical Details:
|
|
# - Cache key is based on func.script(show_meta=True) hash
|
|
# - Python comments do NOT affect cache key (not in TIR)
|
|
# - Must use .with_attr("global_symbol", ...) to create unique cache keys
|
|
|
|
import pytest
|
|
import tilelang
|
|
import tilelang.testing
|
|
import tilelang.language as T
|
|
import tvm_ffi
|
|
import torch
|
|
import uuid
|
|
from pathlib import Path
|
|
from tilelang.env import env
|
|
from tilelang.cache import _dispatch_map
|
|
|
|
BACKENDS = [
|
|
"tvm_ffi",
|
|
"cython",
|
|
"nvrtc",
|
|
"cutedsl",
|
|
]
|
|
|
|
|
|
def _get_target_from_backend(backend: str):
|
|
"""Map backend to target string."""
|
|
return "cutedsl" if backend == "cutedsl" else "auto"
|
|
|
|
|
|
def _require_backend_available(backend: str) -> None:
|
|
if backend != "cutedsl":
|
|
return
|
|
try:
|
|
from tilelang.jit.adapter.cutedsl.checks import check_cutedsl_available
|
|
|
|
check_cutedsl_available()
|
|
except ImportError as e:
|
|
pytest.skip(f"CuTeDSL backend unavailable: {e}")
|
|
|
|
|
|
class PostProcCounter:
|
|
"""Track postproc callback invocations with a simple counter."""
|
|
|
|
def __init__(self):
|
|
self.count = 0
|
|
self.marker = None
|
|
|
|
def register_callback(self, backend: str):
|
|
"""Register postproc callback for the given backend."""
|
|
comment_prefix = "#" if backend == "cutedsl" else "//"
|
|
global_func = "tilelang_callback_cutedsl_postproc" if backend == "cutedsl" else "tilelang_callback_cuda_postproc"
|
|
|
|
def callback(code, _):
|
|
self.count += 1
|
|
self.marker = f"{comment_prefix} CACHE_TEST_MARKER_{self.count}"
|
|
return self.marker + "\n" + code
|
|
|
|
tvm_ffi.register_global_func(global_func, f=callback, override=True)
|
|
return callback
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def setup_module_env():
|
|
"""Setup and restore module-level environment and cache state."""
|
|
# Save original env values
|
|
original_cache_dir = env.TILELANG_CACHE_DIR
|
|
|
|
# Enable cache once for entire module
|
|
tilelang.enable_cache()
|
|
|
|
yield
|
|
|
|
# Restore env at module end
|
|
env.TILELANG_CACHE_DIR = original_cache_dir
|
|
|
|
# Restore default postproc callbacks
|
|
tvm_ffi.register_global_func("tilelang_callback_cuda_postproc", f=lambda code, _: code, override=True)
|
|
tvm_ffi.register_global_func("tilelang_callback_cutedsl_postproc", f=lambda code, _: code, override=True)
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def clean_cache_env(tmp_path, request):
|
|
"""Provide isolated cache environment for each test.
|
|
|
|
Creates an isolated cache directory to ensure:
|
|
- No interference from previous test runs
|
|
- No interference between parallel tests
|
|
- Clean slate for testing cache miss/hit behavior
|
|
- Env restoration is handled by setup_module_env at module scope
|
|
"""
|
|
# This fixture should ONLY be used with @pytest.mark.parametrize("backend", ...)
|
|
backend = request.node.callspec.params["backend"] # Will raise KeyError if missing
|
|
_require_backend_available(backend)
|
|
|
|
cache_dir = tmp_path / "tilelang_cache"
|
|
cache_dir.mkdir()
|
|
|
|
# Patch the cache root to point to an isolated directory.
|
|
env.TILELANG_CACHE_DIR = str(cache_dir)
|
|
|
|
# Clear memory caches to force disk I/O
|
|
_dispatch_map[backend]._memory_cache.clear()
|
|
|
|
return cache_dir
|
|
|
|
|
|
@tilelang.testing.requires_cuda
|
|
@pytest.mark.parametrize("backend", BACKENDS)
|
|
def test_disk_cache_with_postproc(clean_cache_env, backend):
|
|
"""Test disk cache for multiple backends using postproc callback.
|
|
|
|
Tests all CUDA-based backends: nvrtc, cutedsl
|
|
(tvm_ffi, cython, torch use the same cuda_postproc callback as nvrtc)
|
|
|
|
Verification logic:
|
|
1. Pass 1: cache miss → postproc called → marker in kernel source
|
|
2. Pass 2: cache hit → postproc NOT called → same marker still in source
|
|
3. Verify cache files created on disk
|
|
4. Verify functional correctness
|
|
"""
|
|
counter = PostProcCounter()
|
|
counter.register_callback(backend)
|
|
|
|
# Use UUID in global_symbol to ensure unique cache key per test run
|
|
unique_id = uuid.uuid4().hex[:8]
|
|
M, N = 256, 256
|
|
|
|
@T.prim_func
|
|
def vector_add(
|
|
A: T.Tensor((M, N), T.float32),
|
|
B: T.Tensor((M, N), T.float32),
|
|
C: T.Tensor((M, N), T.float32),
|
|
):
|
|
with T.Kernel(M, threads=256) as bx:
|
|
for i in T.serial(N):
|
|
C[bx, i] = A[bx, i] + B[bx, i]
|
|
|
|
kernel_func = vector_add.with_attr("global_symbol", f"vector_add_{backend}_{unique_id}")
|
|
|
|
# === Pass 1: Cache miss (memory cache already cleared by fixture) ===
|
|
kernel1 = tilelang.compile(
|
|
kernel_func,
|
|
out_idx=[2],
|
|
target=_get_target_from_backend(backend),
|
|
execution_backend=backend,
|
|
)
|
|
|
|
assert counter.count == 1, f"Cache miss: postproc should be called once, got {counter.count}"
|
|
|
|
source1 = kernel1.get_kernel_source()
|
|
assert counter.marker in source1, f"Expected marker '{counter.marker}' in kernel source"
|
|
|
|
# Verify cache files created
|
|
cache_files = list(Path(clean_cache_env).rglob("*.*"))
|
|
assert len(cache_files) > 0, "Cache files should be created, found none"
|
|
|
|
# === Pass 2: Cache hit (clear memory cache to force disk read) ===
|
|
_dispatch_map[backend]._memory_cache.clear()
|
|
|
|
kernel2 = tilelang.compile(
|
|
kernel_func,
|
|
out_idx=[2],
|
|
target=_get_target_from_backend(backend),
|
|
execution_backend=backend,
|
|
)
|
|
|
|
assert counter.count == 1, f"Cache hit: postproc should not be called again, got {counter.count} calls"
|
|
if backend == "nvrtc":
|
|
assert kernel1.adapter.kernels is not kernel2.adapter.kernels
|
|
|
|
source2 = kernel2.get_kernel_source()
|
|
assert counter.marker in source2, f"Expected cached marker '{counter.marker}' in source"
|
|
|
|
# === Verify functional correctness ===
|
|
a = torch.randn(M, N, dtype=torch.float32).cuda()
|
|
b = torch.randn(M, N, dtype=torch.float32).cuda()
|
|
|
|
c1 = kernel1(a, b)
|
|
c2 = kernel2(a, b)
|
|
ref = a + b
|
|
|
|
torch.testing.assert_close(c1, ref)
|
|
torch.testing.assert_close(c2, ref)
|
|
torch.testing.assert_close(c1, c2)
|
|
|
|
|
|
@tilelang.testing.requires_cuda
|
|
def test_tvm_ffi_export_library_after_disk_cache_hit(tmp_path, monkeypatch):
|
|
cache_dir = tmp_path / "tilelang_cache"
|
|
cache_dir.mkdir()
|
|
monkeypatch.setattr(env, "TILELANG_CACHE_DIR", str(cache_dir))
|
|
|
|
cache = _dispatch_map["tvm_ffi"]
|
|
cache._memory_cache.clear()
|
|
|
|
@T.prim_func
|
|
def vector_add(
|
|
A: T.Tensor((128,), T.float32),
|
|
B: T.Tensor((128,), T.float32),
|
|
):
|
|
with T.Kernel(128, threads=128) as i:
|
|
B[i] = A[i] + 1.0
|
|
|
|
kernel_func = vector_add.with_attr("global_symbol", f"export_library_{uuid.uuid4().hex[:8]}")
|
|
cold_kernel = tilelang.compile(kernel_func, out_idx=[1], execution_backend="tvm_ffi")
|
|
cold_library = tmp_path / "cold.so"
|
|
cold_kernel.export_library(str(cold_library))
|
|
|
|
cache._memory_cache.clear()
|
|
cached_kernel = tilelang.compile(kernel_func, out_idx=[1], execution_backend="tvm_ffi")
|
|
cached_library = tmp_path / "cached.so"
|
|
cached_kernel.export_library(str(cached_library))
|
|
|
|
assert cold_library.is_file()
|
|
assert cached_kernel.artifact is None
|
|
assert cached_library.read_bytes() == Path(cached_kernel.adapter.libpath).read_bytes()
|
|
assert tilelang.tvm.runtime.load_module(str(cached_library)) is not None
|
|
|
|
|
|
@tilelang.testing.requires_cuda
|
|
@pytest.mark.parametrize("backend", BACKENDS)
|
|
def test_cache_miss_detection(clean_cache_env, backend):
|
|
"""Verify cache correctly misses when function changes.
|
|
|
|
This ensures our testing method is valid - different functions should
|
|
produce different cache keys and trigger recompilation.
|
|
"""
|
|
counter = PostProcCounter()
|
|
counter.register_callback(backend)
|
|
|
|
M, N = 128, 128
|
|
|
|
# Kernel 1: A + 1.0
|
|
@T.prim_func
|
|
def func1(A: T.Tensor((M, N), T.float32), B: T.Tensor((M, N), T.float32)):
|
|
with T.Kernel(M, threads=128) as bx:
|
|
for i in T.serial(N):
|
|
B[bx, i] = A[bx, i] + 1.0
|
|
|
|
unique_id_1 = uuid.uuid4().hex[:8]
|
|
kernel_func1 = func1.with_attr("global_symbol", f"func1_{backend}_{unique_id_1}")
|
|
|
|
tilelang.compile(
|
|
kernel_func1,
|
|
out_idx=[1],
|
|
target=_get_target_from_backend(backend),
|
|
execution_backend=backend,
|
|
)
|
|
assert counter.count == 1, f"First kernel: expected 1 call, got {counter.count}"
|
|
|
|
# Kernel 2: A + 2.0 (different implementation)
|
|
@T.prim_func
|
|
def func2(A: T.Tensor((M, N), T.float32), B: T.Tensor((M, N), T.float32)):
|
|
with T.Kernel(M, threads=128) as bx:
|
|
for i in T.serial(N):
|
|
B[bx, i] = A[bx, i] + 2.0 # Different!
|
|
|
|
unique_id_2 = uuid.uuid4().hex[:8]
|
|
kernel_func2 = func2.with_attr("global_symbol", f"func2_{backend}_{unique_id_2}")
|
|
|
|
tilelang.compile(
|
|
kernel_func2,
|
|
out_idx=[1],
|
|
target=_get_target_from_backend(backend),
|
|
execution_backend=backend,
|
|
)
|
|
|
|
assert counter.count == 2, f"Different function should cause cache miss, expected 2 calls, got {counter.count}"
|
|
|
|
|
|
@tilelang.testing.requires_cuda
|
|
@pytest.mark.parametrize("backend", BACKENDS)
|
|
def test_cache_isolation_between_tests(clean_cache_env, backend):
|
|
"""Verify cache isolation between tests.
|
|
|
|
Ensures clean_cache_env fixture provides independent cache directory for each test.
|
|
"""
|
|
# Verify cache directory is empty
|
|
cache_files = list(Path(clean_cache_env).rglob("*"))
|
|
assert all(f.is_dir() for f in cache_files), f"Cache should be empty, found: {cache_files}"
|
|
|
|
# Compile a kernel
|
|
counter = PostProcCounter()
|
|
counter.register_callback(backend)
|
|
|
|
unique_id = uuid.uuid4().hex[:8]
|
|
|
|
@T.prim_func
|
|
def simple(A: T.Tensor((128,), T.float32), B: T.Tensor((128,), T.float32)):
|
|
with T.Kernel(128, threads=128) as i:
|
|
B[i] = A[i] * 2.0
|
|
|
|
kernel_func = simple.with_attr("global_symbol", f"simple_{backend}_{unique_id}")
|
|
|
|
tilelang.compile(
|
|
kernel_func,
|
|
out_idx=[1],
|
|
target=_get_target_from_backend(backend),
|
|
execution_backend=backend,
|
|
)
|
|
|
|
# Should be cache miss (empty cache dir)
|
|
assert counter.count == 1, f"Expected cache miss, got count={counter.count}"
|
|
|
|
# Verify cache files created
|
|
cache_files_after = list(Path(clean_cache_env).rglob("*.*"))
|
|
assert len(cache_files_after) > 0, f"Cache files should be created, found: {cache_files_after}"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|