NVIDIA Developer ★ 100 6 min

Translating CUDA Tile Operations from Python to Rust Using Agentic AI

Agentic AI / Generative AIDeveloper Tools & TechniquesAI AgentCUDA TilecuTileMixture of Experts (MoE)Python

🔗 https://developer.nvidia.com/blog/translating-cuda-tile-operations-from-python-to-rust-using-agentic-ai/

📌 用Agentic AI把CUDA Tile Kernel從Python搬到Rust,效能只掉0.5%

TL;DR:NVIDIA用多agent pipeline將24個TileGym kernel從Python移植到Rust,效能達99.5%。

一套累積了大量生產級kernel的CUDA函式庫,要換一種程式語言重寫,通常意味著大量人工、大量bug,以及效能倒退的風險。NVIDIA團隊反其道而行:建了一個agent skill,讓AI agent自己完成從cuTile Python搬到cuTile Rust的轉譯工作,而且轉出來的kernel平均效能只比原本少0.5%。

🤔 JIT的隱式特化,遇上Rust的顯式宣告

cutile-rs(cuTile Rust)是一套在Rust中撰寫GPU kernel的tile-based系統,把Rust的ownership模型延伸到tile運算,將可變輸出切成互不重疊的區塊,並在host端跨kernel launch維持ownership契約,同時允許開發者在需要更底層控制時局部退出、直接操作Tile IR。

TileGym這個CUDA tile kernel函式庫已經累積大量用cuTile Python與Triton-TileIR寫成的生產kernel,NVIDIA團隊希望這些kernel在Rust中也能用。轉譯的主要難題在於:cuTile Python的JIT編譯器會在呼叫當下隱式地為每個kernel做特化,而Rust要求你在kernel簽名中明確宣告每一種特化情境。舉例來說,Python裡未走到的if ct.Constant分支在編譯前就會被丟棄,但在Rust中兩條分支都必須通過型別檢查;一個Python kernel因此可能拆成多個結構不同的Rust entry(例如layer_norm會因為分支改變tile rank,拆成2-D的nchw與1-D的w1兩個entry)。

🧩 每個轉譯階段都有機器可驗證的判定

三種前端(cuTile Python、Triton-TileIR、cuTile Rust)其實都是同一份CUDA Tile IR(cuda_tile dialect)的表層語法,最終都餵給同一個tileiras編譯器,產出相同的GPU binary。這個共享的IR基礎讓轉譯不是「重新最佳化」的問題,而是「用更安全的host語言重新表達同一個tile程式」,底層編譯器與效能模型完全相同。

正因為三種前端輸出同一種dialect,一次忠實的移植應該重現參考kernel的IR結構:相同的memory-op家族、相同的tile shape、相同的reduction。團隊因此可以直接把參考kernel與轉譯後kernel的Tile IR dump出來做diff,在跑任何測試之前就先結構性地驗證正確性——這能抓出「看似正確但實際有問題」的轉譯,例如TMA load用錯cost hint,或漏掉divisibility屬性;這類問題可能通過功能測試,卻在測試覆蓋不到的地方造成效能退化。

整個轉譯流程是一個有邊界的多agent pipeline,依序涵蓋分析、device kernel撰寫、host與FFI程式碼、以及benchmark,每個階段都由validator script與Tile IR diff給出可機器判讀的結果,決定是否放行進入下一階段。文章以softmax kernel為例,逐行對照展示cuTile Python中的ct.load、ct.max、ct.exp等操作,如何對應成Rust中的make_partition_view、reduce_max、exp等呼叫,兩者結構幾乎一一對應,這正是因為兩種前端都只是同一組Tile IR操作的薄層介面。

📊 24個算子、約40個kernel,效能達99.5%

團隊用這套skill把全部24個公開的TileGym算子(合計約40個GPU kernel,涵蓋element-wise運算到flash-attention decode、Multi-head Latent Attention(MLA)、mixture-of-experts(MoE)等)移植到cuTile Rust,平均達到cuTile Python效能的99.5%。

⚠️ 顯式化不是免費的,安全網也要另外補

除了branch必須全部型別檢查外,任何dtype組合在Python中可即時編譯,但在Rust中FFI要透過固定的symbol/dtype table分派,代表新增一種dtype支援是明確的ABI擴充工作(範例中共用的table涵蓋f32/f16/bf16/i32/i64/f8e5m2/f8e4m3fn)。另外,Python的型別系統原本兼任輸入驗證,但跨過C ABI之後就沒有安全網,一個錯的stride可能造成靜默的記憶體損毀而非丟出例外,團隊為此在Python wrapper加上語意檢查,並在FFI背後加上帶命名回傳碼的ABI檢查(null/dtype/device)作為兩層防護。

🎯 實務啟示

這個案例示範了agentic AI在「跨語言移植底層系統程式碼」這種高風險任務上的可行做法:關鍵不是讓agent一次生成完美程式碼,而是把每個轉譯階段都綁定一個機器可驗證的判定(IR diff、functional test、benchmark),讓agent的輸出可以被結構性地審查,而不只是靠通過測試來背書。這套skill已隨TileGym repo釋出,工程師可以直接套用在自己的kernel移植工作上。

🔗 來源

#CUDA #Rust #GPU #AgenticAI #NVIDIA #TileGym #CompilerEngineering #SystemsProgramming #GPUKernel #Triton

原始資料 NVIDIA Developer · 收集於 2026-09-16
來源原標題
Translating CUDA Tile Operations from Python to Rust Using Agentic AI
作者
Tanya Lenz
原始標籤
Agentic AI / Generative AI · Developer Tools & Techniques · AI Agent · CUDA Tile · cuTile · Mixture of Experts (MoE) · Python
原始連結
https://developer.nvidia.com/blog/translating-cuda-tile-operations-from-python-to-rust-using-agentic-ai/

摘要原文

cuTile Rust ( cutile-rs ) is a tile-based system for safe, idiomatic GPU kernel authoring in the Rust programming language. Extending the Rust ownership model to tile-based GPU kernels, it splits mutable outputs into disjoint pieces and preserves the host-side ownership contract across kernel launches. It also allows programmers to opt out locally when they need lower-level control, enabling direct execution of Tile IR operations. The TileGym CUDA tile kernel library has accumulated a large library of production kernels written in CUDA Tile Python ( cuTile Python ) and Triton-TileIR ( nvtriton ). To make all of these kernels available in Rust as well, our team built an AI agent skill that translates cuTile Python and Triton-TileIR kernels into cuTile Rust. Using this skill, we ported all 24 public TileGym operators to cuTile Rust and reached 99.5% of cuTile Python performance on average. They contain roughly 40 GPU kernels in total, ranging from element-wise operations to flash-attention decode, Multi-head Latent Attention (MLA), and mixture-of-experts (MoE) models. Note that some operators need multiple kernel variants. Each conversion starts from whichever reference implementation the operator has (cuTile Python or Triton-TileIR) and runs through a bounded multi-agent pipeline covering analysis, the device kernel, host and FFI code, and benchmarking. Every stage ends in a machine-checkable verdict, with validator scripts and Tile IR diffs deciding whether a conversion moves forward. The main challenge is that cuTile Python JIT compilation specializes each kernel implicitly at call time, whereas Rust requires that you declare every specialization in the kernel’s signature. This post explains how we developed a multi-agent workflow to translate cuTile Python and Triton-TileIR kernels into cuTile Rust, with checks for correctness and performance at each stage. It covers what the gap looks like in a real kernel, how the skill is structured so that no stage has to be taken on trust, and how the resulting kernels perform against their references. The skill ships in the TileGym repo, so you can apply it to your own kernels. Kernel translation between Tile IR front ends cuTile Python, Triton-TileIR, and cuTile Rust are three front ends over the same IR: CUDA Tile IR , the cuda_tile dialect. All three feed the same tileiras compiler, which performs the tile-level optimizations and emits the GPU binary. This shared foundation makes translating across the CUDA Tile family practical and, just as important, verifiable. cuTile Python ─┐ Triton-TileIR ─┼─► CUDA Tile IR (cuda_tile dialect) ─► tileiras ─► cubin cuTile Rust ─┘ The TileGym production tile kernels are written against the first two front ends. Because all three meet at the same IR, porting a kernel to cuTile Rust is not a re-optimization problem. It is re-expressing the same tile program in a safer host language, with the same compiler and the same performance model underneath. The shared IR makes translation checkable. A faithful port should reproduce the reference kernel’s IR structure: the same memory-op families, same tile shapes, and same reductions. Because all three front ends emit the same dialect, this can be directly verified by dumping the reference kernel Tile IR and the translated kernel Tile IR and “diffing” them before a single test is executed. This enables checking the agent’s output structurally, not just functionally. A wrong-but-plausible translation (a TMA load with wrong cost hint or a dropped divisibility attribute, for example) can pass tests yet still be incorrect outside of test coverage and may bring performance regressions. These issues can be easily checked and fixed by comparing with the reference IR. The IR diff stage is central to the pipeline described in this post. Two additional aspects of the Rust front end are important to note for this discussion. First, the Rust source is compiled ahead of time. Tile shapes and element types are checked by rustc . The crate embeds the kernel AST, and at first launch the runtime specializes it with the concrete const-generic values and compiles a cubin (cached thereafter). The GPU binary itself is still JIT-compiled, but the implicitness is gone: nothing is specialized unless the kernel signature declares it. Second, in TileGym, cuTile Rust is simply another backend. tilegym.set_backend("cutile-rs") routes the same operator API to the Rust kernels. Making specialization explicit The two front ends differ in where specialization happens. cuTile Python JIT specializes on whatever it sees at call time. cuTile Rust specializes only on what the kernel signature declares. Most of the translation work comes from spelling out what the Python source leaves implicit. The main cases are summarized in the following table. cuTile Python (implicit JIT) cuTile Rust (AOT Rust source) Consequence for translation Untaken if ct.Constant branches are dropped before compilation Both branches must type-check One Python kernel becomes multiple structural Rust entries (for example, layer_norm splits into 2-D nchw and 1-D w1 entries because the branch changes tile rank) Any dtype combination compiles on demand The FFI dispatches over a fixed symbol/dtype table Supporting a dtype is an explicit ABI extension; the shared table spans f32/f16/bf16/i32/i64/f8e5m2/f8e4m3fn The JIT type system is the input validation Past the C ABI there is no safety net, so a wrong stride is a silent corruption, not an exception Two defensive layers: semantic checks in the Python wrapper, ABI checks ( null/dtype/device ) behind the FFI with named return codes Table 1. Examples of cuTile Python-Rust translation gaps The following section illustrates these differences using a real kernel example. Softmax translation example This example kernel is intentionally simple so you can compare the two versions line by line. First, in cuTile Python: @ct.kernel def softmax_kernel(output, input, TILE_SIZE: Constant[int]): row_idx = ct.bid(0) # one CTA per row row = ct.load(input, index=(row_idx, 0), shape=(1, TILE_SIZE), padding_mode=ct.PaddingMode.NEG_INF) row = ct.astype(row, ct.float32) row_max = ct.max(row, axis=1, keepdims=True) numerator = ct.exp(row - row_max) denominator = ct.sum(numerator, axis=1, keepdims=True) out = numerator / denominator out = ct.astype(out, input.dtype) ct.store(output, index=(row_idx, 0), tile=out) And the same kernel in cuTile Rust: #[cutile::module] pub mod softmax_module { use cutile::core::*; #[cutile::entry()] pub fn softmax_kernel<E: ElementType, const TILE_SIZE: i32>( output: &mut Tensor<E, { [1, TILE_SIZE] }>, // one row per CTA input: &Tensor<E, { [-1, -1] }>, ) { let row_idx = get_tile_block_id().0; // ct.bid(0) // ct.load(..., padding_mode=NEG_INF): a safe partition view whose ragged // columns pad with -inf, then a load of this CTA's row. let token: Token = get_tensor_token(input); let row_view: Partition<E, { [1, TILE_SIZE] }> = make_partition_view( input, const_shape![1, TILE_SIZE], padding::NegInf, dim_map::Identity, token); let row: Tile<E, { [1, TILE_SIZE] }> = row_view.load([row_idx, 0i32]); let row: Tile<f32, { [1, TILE_SIZE] }> = convert_tile(row); // ct.astype(f32) let row_max: Tile<f32, { [1] }> = reduce_max(row, 1i32); let shifted = row - row_max.reshape(const_shape![1, 1]) .broadcast(const_shape![1, TILE_SIZE]); let numerator: Tile<f32, { [1, TILE_SIZE] }> = exp(shifted); let denominator: Tile<f32, { [1] }> = reduce_sum(numerator, 1i32); let out = numerator / denominator.reshape(const_shape![1, 1]) .broadcast(const_shape![1, TILE_SIZE]); let out: Tile<E, { [1, TILE_SIZE] }> = convert_tile(out); // ct.astype(dtype) output.store(out); // ct.store } } You can read the correspondences directly. They’re this clean because both front ends are thin surfaces over the same Tile IR ops: Constant[int] parameters become const generics ( const TILE_SIZE: i32 ), instantiated per launch shape by the host through the same Tile IR JIT. ct.load(...

由 claude-code/sonnet 自動生成