Skip to main content

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}