teeny_compiler/compiler/backend/llvm/compiler.rs
1/*
2 * Copyright (c) 2026 Teenygrad.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use std::fs::{File, create_dir_all};
18use std::io::Write;
19use std::path::PathBuf;
20use std::process::Command;
21
22use derive_more::Display;
23use sha2::{Digest, Sha256};
24use teeny_core::compiler::{Compiler, Target};
25use teeny_core::device::program::Kernel;
26use tracing::info;
27
28use crate::errors::Result;
29
30/// `teenyc`'s own diagnostic verbosity, from least to most verbose.
31///
32/// Passed to `teenyc` via `RUSTC_LOG` (its standard rustc-derived logging env var), scoped to
33/// just the MLIR backend's `tracing` target (`rustc_codegen_llvm::mlir`) so unrelated `rustc`
34/// internals stay quiet. At `Debug`, the MLIR backend logs each pipeline stage's IR once (ttir,
35/// ttgpuir, llir, llvmir, ptx/asm). At `Trace`, it additionally logs IR before/after every
36/// individual MLIR pass within ttir/ttgpuir/llir — much more output.
37///
38/// `teenyc`'s captured stderr is relayed back through this process's own `tracing` (see
39/// [`LlvmCompiler::compile`]), so it lands wherever the caller's subscriber routes `teeny_compiler`
40/// events — it is not printed directly to the terminal.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
42pub enum LogLevel {
43 /// `error`.
44 #[display("error")]
45 Error,
46 /// `warn`.
47 #[display("warn")]
48 Warn,
49 /// `info`.
50 #[display("info")]
51 Info,
52 /// `debug`.
53 #[display("debug")]
54 Debug,
55 /// `trace`.
56 #[display("trace")]
57 Trace,
58}
59
60impl std::str::FromStr for LogLevel {
61 type Err = String;
62
63 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
64 match s.trim().to_ascii_lowercase().as_str() {
65 "error" => Ok(Self::Error),
66 "warn" => Ok(Self::Warn),
67 "info" => Ok(Self::Info),
68 "debug" => Ok(Self::Debug),
69 "trace" => Ok(Self::Trace),
70 other => Err(format!(
71 "unknown log level '{other}'; expected one of error, warn, info, debug, trace"
72 )),
73 }
74 }
75}
76
77/// `tracing` target `teenyc`'s MLIR backend logs pipeline-stage IR under; see [`LogLevel`].
78const TEENYC_MLIR_LOG_TARGET: &str = "rustc_codegen_llvm::mlir";
79
80/// Compiles kernels by shelling out to the custom `teenyc` compiler (`-Zcodegen-backend=mlir`)
81/// at runtime. See [`crate::compiler::find_teenyc`] for how its path is resolved, and the crate
82/// docs for the `cargo-teeny` setup this requires.
83#[derive(Debug, Clone)]
84pub struct LlvmCompiler {
85 teenyc_path: PathBuf,
86 cache_dir: PathBuf,
87 target_cpu: Option<String>,
88 ptx_version: Option<u32>,
89 log_level: Option<LogLevel>,
90}
91
92impl LlvmCompiler {
93 /// Creates a compiler that invokes the `teenyc` binary at `teenyc_path`, caching compiled
94 /// kernels under `cache_dir` (created if it doesn't exist).
95 ///
96 /// `ptx_version` defaults from `$TEENYC_PTX_VERSION` if set (parse failures are ignored,
97 /// falling back to `None`/teenyc's own default). This matters beyond the compile itself:
98 /// [`Compiler::compile`]'s cache-key hash folds in `ptx_version`, so a JIT/runtime call site
99 /// that never explicitly calls [`Self::with_ptx_version`] — e.g. a deployed binary just
100 /// looking up an AOT-precompiled kernel cache, with no live `teenyc` to fall back to — must
101 /// still agree with whatever override `cargo teeny package --options ptx-version=NN` used at
102 /// AOT time, or every lookup misses. Reading the env var here means one `.env` entry (see
103 /// `TEENYC_PTX_VERSION` in the deployed package's env) keeps both sides in sync without every
104 /// call site having to thread the value through by hand.
105 ///
106 /// `log_level` similarly defaults from `$TEENYC_LOG_LEVEL` if set (parse failures are
107 /// ignored, falling back to `None`) — this lets any call site, including ones that don't
108 /// (or can't, e.g. a fixed helper like [`crate::compiler::driver::cuda::compile_kernel`])
109 /// call [`Self::with_log_level`] directly, turn on pipeline-stage logging via the environment.
110 pub fn new(teenyc_path: impl Into<PathBuf>, cache_dir: impl Into<PathBuf>) -> Result<Self> {
111 let teenyc_path = teenyc_path.into();
112 let cache_dir = cache_dir.into();
113
114 if !cache_dir.exists() {
115 create_dir_all(&cache_dir)?;
116 }
117
118 let ptx_version = std::env::var("TEENYC_PTX_VERSION").ok().and_then(|v| {
119 v.parse().ok().or_else(|| {
120 tracing::warn!(value = %v, "TEENYC_PTX_VERSION is not a valid u32; ignoring");
121 None
122 })
123 });
124
125 let log_level = std::env::var("TEENYC_LOG_LEVEL").ok().and_then(|v| {
126 v.parse().ok().or_else(|| {
127 tracing::warn!(value = %v, "TEENYC_LOG_LEVEL is not a valid log level; ignoring");
128 None
129 })
130 });
131
132 Ok(Self {
133 teenyc_path,
134 cache_dir,
135 target_cpu: None,
136 ptx_version,
137 log_level,
138 })
139 }
140
141 /// Sets the target GPU architecture (e.g. `sm_90`) passed to `teenyc` as `-Ctarget-cpu`.
142 pub fn with_target_cpu(mut self, cpu: impl Into<String>) -> Self {
143 self.target_cpu = Some(cpu.into());
144 self
145 }
146
147 /// Override the PTX ISA version `teenyc` stamps into the generated PTX
148 /// (encoded as `major*10 + minor`, e.g. `82` for `8.2`), via
149 /// `TEENYC_PTX_VERSION`. Without this, `teenyc` picks a conservative
150 /// default from the target capability — set this when the deployment
151 /// target's exact CUDA version is known and needs a precise match.
152 pub fn with_ptx_version(mut self, ptx_version: u32) -> Self {
153 self.ptx_version = Some(ptx_version);
154 self
155 }
156
157 /// Sets `teenyc`'s diagnostic verbosity (see [`LogLevel`]). Left unset (the default),
158 /// `teenyc` uses its own default (roughly `warn`) and no pipeline-stage IR is captured.
159 pub fn with_log_level(mut self, log_level: LogLevel) -> Self {
160 self.log_level = Some(log_level);
161 self
162 }
163}
164
165impl Compiler for LlvmCompiler {
166 fn compile(&self, kernel: &impl Kernel, _target: &impl Target, force: bool) -> Result<String> {
167 // Hash the kernel id together with target cpu and ptx version so that
168 // different targets/overrides each get their own cache entry.
169 let effective_id = {
170 let mut h = Sha256::new();
171 h.update(kernel.id().as_bytes());
172 if let Some(cpu) = &self.target_cpu {
173 h.update(cpu.as_bytes());
174 }
175 if let Some(ptx_version) = self.ptx_version {
176 h.update(ptx_version.to_le_bytes());
177 }
178 h.finalize()
179 .iter()
180 .map(|b| format!("{b:02x}"))
181 .collect::<String>()
182 };
183 let kernel_file_name = format!("{}_{}", kernel.name(), effective_id);
184 let kernel_file = self.cache_dir.join(&kernel_file_name).with_extension("rs");
185 let output_file = self
186 .cache_dir
187 .join(kernel_file_name.clone())
188 .with_extension("o");
189
190 if !output_file.exists() || force {
191 anyhow::ensure!(
192 self.teenyc_path.exists(),
193 "kernel not cached and rustc not found at {:?}; \
194 set TEENYC_PATH to a valid rustc binary",
195 self.teenyc_path
196 );
197
198 // Two callers compiling the *same* kernel hash concurrently (e.g. under `cargo
199 // test`'s default parallelism) must not both write `kernel_file`/invoke `teenyc`
200 // at once -- that races on the shared source path and can corrupt the output.
201 // A lock file scoped to this exact hash serializes only that collision: unrelated
202 // hashes use different lock files and keep compiling fully in parallel. The lock
203 // file itself is intentionally never deleted -- unlinking it here would race a
204 // concurrent locker into flock'ing a since-replaced inode, defeating the lock.
205 let lock_path = self.cache_dir.join(format!("{kernel_file_name}.lock"));
206 let mut lock = fd_lock::RwLock::new(File::create(&lock_path)?);
207 let _guard = lock.write()?;
208
209 // Double-check after acquiring the lock: if another process compiled (and
210 // released the lock for) this exact hash while we were waiting, reuse its
211 // output rather than redundantly recompiling. This -- not just avoiding the
212 // corrupted-write symptom -- is the actual point of taking the lock.
213 if !output_file.exists() || force {
214 let mut file = File::create(&kernel_file)?;
215
216 info!("Writing kernel code to file");
217 file.write_all(teeny_triton::triton_lang::TRITON.as_bytes())?;
218 file.write_all(kernel.source().as_bytes())?;
219
220 // Compile to a unique per-process temp path and atomically rename it into
221 // place afterwards (POSIX `rename` is atomic within the same directory), so
222 // any reader of `output_file` never observes a partial write.
223 let tmp_output_file = self
224 .cache_dir
225 .join(format!("{kernel_file_name}.o.tmp.{}", std::process::id()));
226
227 let mut cmd = Command::new(&self.teenyc_path);
228 cmd.arg(&kernel_file)
229 .arg("-Copt-level=3")
230 .arg("-Zcodegen-backend=mlir")
231 .arg("--emit=obj")
232 .arg(format!("-o{}", tmp_output_file.display()))
233 .arg("--target=nvptx64-nvidia-cuda")
234 .arg("--crate-type=lib")
235 .arg("-C")
236 .arg("overflow-checks=off")
237 .arg("--frontend=triton")
238 .current_dir(&self.cache_dir)
239 // `-Zcodegen-backend` is an unstable flag; `teenyc` is distributed on the
240 // "stable" channel (real version numbers, normal feature-gating), so without
241 // this it refuses with "the option `Z` is only accepted on the nightly
242 // compiler". `RUSTC_BOOTSTRAP=1` is the standard, narrowly-scoped way to permit
243 // specific unstable flags against a stable-channel compiler (the same mechanism
244 // rustc's own bootstrap, bindgen, and miri's installer rely on) without needing
245 // to distribute `teenyc` itself as a nightly build.
246 .env("RUSTC_BOOTSTRAP", "1");
247 if let Some(cpu) = &self.target_cpu {
248 cmd.arg(format!("-Ctarget-cpu={cpu}"));
249 }
250 if let Some(ptx_version) = self.ptx_version {
251 cmd.env("TEENYC_PTX_VERSION", ptx_version.to_string());
252 }
253 if let Some(log_level) = self.log_level {
254 cmd.env("RUSTC_LOG", format!("{TEENYC_MLIR_LOG_TARGET}={log_level}"));
255 }
256 let output = cmd.output()?;
257
258 // `teenyc`'s own tracing subscriber writes to its stderr; relay it through this
259 // process's tracing rather than printing directly, so it's filterable/routable like
260 // any other event here. Only worth the string conversion when logging was requested.
261 if self.log_level.is_some() {
262 for line in String::from_utf8_lossy(&output.stderr).lines() {
263 tracing::debug!(target: "teeny_compiler::llvm::teenyc", "{line}");
264 }
265 }
266
267 if !output.status.success() {
268 let _ = std::fs::remove_file(&tmp_output_file);
269 let _ = std::fs::remove_file(tmp_output_file.with_extension("mlir"));
270 let stderr = String::from_utf8_lossy(&output.stderr);
271 anyhow::bail!("rustc exited with status {}\n{}", output.status, stderr);
272 }
273
274 // `teenyc` also writes a `.mlir` sidecar next to the object file (the
275 // pre-Triton MLIR source, read back by e.g. the `*_mlir_output` snapshot
276 // tests) -- derived from the same `-o` path, so it needs the same
277 // temp-then-rename treatment. It may not exist for every invocation, hence
278 // the existence check rather than treating a missing file as an error.
279 let tmp_mlir_file = tmp_output_file.with_extension("mlir");
280 if tmp_mlir_file.exists() {
281 std::fs::rename(&tmp_mlir_file, output_file.with_extension("mlir"))?;
282 }
283
284 std::fs::rename(&tmp_output_file, &output_file)?;
285 }
286 }
287
288 Ok(output_file.to_string_lossy().to_string())
289 }
290}