Skip to content

Commit 65264c0

Browse files
committed
fix(simulator): one compiler per AOT-C artifact via a cache lock file
1 parent ea2715c commit 65264c0

1 file changed

Lines changed: 270 additions & 11 deletions

File tree

  • crates/simulator/src/backend/aot_c

crates/simulator/src/backend/aot_c/emit.rs

Lines changed: 270 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ use crate::ir::{
1515
};
1616
use std::collections::HashMap;
1717
use std::ffi::c_void;
18+
use std::io::ErrorKind;
1819
use std::path::{Path, PathBuf};
19-
use std::process::Command;
20+
use std::process::{Command, Stdio};
2021
use std::sync::mpsc::Sender;
2122
use std::sync::{Arc, Mutex, OnceLock};
23+
use std::time::Duration;
2224
use veryl_analyzer::ir::Op;
2325
use veryl_analyzer::value::Value;
2426

@@ -2862,7 +2864,22 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
28622864
]);
28632865
let so_path = cache_dir.join(format!("veryl_aot_{hash}.so"));
28642866

2865-
if !so_path.exists() {
2867+
// One compiler per artifact hash, across processes and pool workers alike.
2868+
// `Published` means someone else landed it while we waited; the second
2869+
// `exists` check closes the window between the first one and the lock.
2870+
let ticket = if so_path.exists() {
2871+
CompileTicket::Published
2872+
} else {
2873+
acquire_compile_lock(cache_dir, &hash, &so_path)
2874+
};
2875+
// Only the unix path hands the lock to the shell; elsewhere `Drop` alone
2876+
// releases it.
2877+
#[cfg(unix)]
2878+
let lock_path = match &ticket {
2879+
CompileTicket::Owned(l) => Some(l.path.clone()),
2880+
CompileTicket::Published | CompileTicket::Unlocked => None,
2881+
};
2882+
if !matches!(ticket, CompileTicket::Published) && !so_path.exists() {
28662883
// Identical sources hash to the same `so_path`, so a `cc -o so_path`
28672884
// from one thread can be dlopened half-written by another. Compile to a
28682885
// unique temp, then `rename`/`mv` (atomic within the dir) to publish.
@@ -2888,9 +2905,14 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
28882905
#[cfg(unix)]
28892906
let out = {
28902907
let mut cmd = Command::new("/bin/sh");
2908+
// The trailing `rm` releases the lock from the script, not just
2909+
// from `Drop`: the shell outlives a killed run, and only it knows
2910+
// when the publish finished. It runs on the failure path too, and
2911+
// an empty $lk (Unlocked ticket) skips it without disturbing the
2912+
// exit status.
28912913
cmd.arg("-c")
28922914
.arg(
2893-
r#"cc="$1"; tso="$2"; tc="$3"; pc="$4"; pso="$5"; shift 5; "$cc" "$@" -o "$tso" "$tc" || { rm -f "$tso"; exit 1; }; mv -f "$tc" "$pc"; mv -f "$tso" "$pso""#,
2915+
r#"cc="$1"; tso="$2"; tc="$3"; pc="$4"; pso="$5"; lk="$6"; shift 6; if "$cc" "$@" -o "$tso" "$tc"; then mv -f "$tc" "$pc"; mv -f "$tso" "$pso"; rc=0; else rm -f "$tso"; rc=1; fi; if [ -n "$lk" ]; then rm -f "$lk"; fi; exit $rc"#,
28942916
)
28952917
.arg("sh")
28962918
.arg(&cc_name)
@@ -2906,8 +2928,18 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
29062928
use std::os::unix::process::CommandExt;
29072929
cmd.process_group(0);
29082930
}
2909-
cmd.output()
2910-
.map_err(|e| format!("spawn sh/cc: {e} (set VERYL_AOT_CC to override)"))?
2931+
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
2932+
let child = cmd
2933+
.spawn()
2934+
.map_err(|e| format!("spawn sh/cc: {e} (set VERYL_AOT_CC to override)"))?;
2935+
// The shell, not us, owns the publish, so its pid is what tells
2936+
// a waiter whether the lock is still live (see `owner_alive`).
2937+
if let Some(lp) = &lock_path {
2938+
let _ = std::fs::write(lp, format!("{}\n", child.id()));
2939+
}
2940+
child
2941+
.wait_with_output()
2942+
.map_err(|e| format!("wait sh/cc: {e}"))?
29112943
};
29122944
#[cfg(not(unix))]
29132945
let out = {
@@ -2969,6 +3001,119 @@ fn compile_source_in(cache_dir: &Path, src: &str) -> Result<EmittedModule, Strin
29693001
Ok(EmittedModule { func, _lib: lib })
29703002
}
29713003

3004+
/// Per-artifact compile lock, released on drop and by the compile script's
3005+
/// trailing `rm` (whichever happens first — the script outlives us when the
3006+
/// process is killed mid-compile).
3007+
struct CompileLock {
3008+
path: PathBuf,
3009+
}
3010+
3011+
impl Drop for CompileLock {
3012+
fn drop(&mut self) {
3013+
let _ = std::fs::remove_file(&self.path);
3014+
}
3015+
}
3016+
3017+
/// Outcome of trying to take the lock for one artifact hash.
3018+
enum CompileTicket {
3019+
/// We own the lock: compile, then release.
3020+
Owned(CompileLock),
3021+
/// Another compiler published the artifact while we waited.
3022+
Published,
3023+
/// The lock file is unusable (unwritable cache dir, filesystem without
3024+
/// `O_EXCL` semantics): compile unlocked, exactly as before the lock.
3025+
Unlocked,
3026+
}
3027+
3028+
/// Backstop for a lock whose owner cannot be identified (the pid is not
3029+
/// written yet, or this is not unix); set above any real compile — the
3030+
/// largest source measured, 87 MB, takes ~4 min. A provably dead owner is
3031+
/// taken over at once instead — see [`owner_alive`].
3032+
const COMPILE_LOCK_STALE: Duration = Duration::from_secs(600);
3033+
3034+
/// Is the compile shell recorded in a lock file still running?
3035+
///
3036+
/// The publishing shell outlives us on purpose, so its pid — not ours — is
3037+
/// what makes a lock meaningful. Without this check a run killed mid-compile
3038+
/// would hold the artifact hostage for [`COMPILE_LOCK_STALE`].
3039+
/// `None` = no pid recorded yet; fall back to the age rule.
3040+
fn owner_alive(lock_path: &Path) -> Option<bool> {
3041+
let pid: u32 = std::fs::read_to_string(lock_path)
3042+
.ok()?
3043+
.trim()
3044+
.parse()
3045+
.ok()?;
3046+
// Linux: procfs is authoritative and free. Guarded by /proc/self so a
3047+
// system without procfs doesn't read every pid as dead.
3048+
if Path::new("/proc/self").exists() {
3049+
return Some(Path::new(&format!("/proc/{pid}")).exists());
3050+
}
3051+
// Other unix: `kill -0` through the shell we already use, so this needs
3052+
// no libc dependency.
3053+
#[cfg(unix)]
3054+
let alive = Command::new("sh")
3055+
.arg("-c")
3056+
.arg(format!("kill -0 {pid} 2>/dev/null"))
3057+
.status()
3058+
.ok()
3059+
.map(|s| s.success());
3060+
#[cfg(not(unix))]
3061+
let alive = None;
3062+
alive
3063+
}
3064+
3065+
/// Take the compile lock for `hash`, waiting for the owner rather than
3066+
/// duplicating its work.
3067+
///
3068+
/// Identical sources hash to one `.so`, so every process and pool worker
3069+
/// reaching the compile would otherwise build the same translation unit
3070+
/// independently — four concurrent `cc1` jobs over one 87 MB source have been
3071+
/// observed within a single run, and concurrent runs duplicate that again.
3072+
/// Waiting costs nothing: `compile_source_in` blocks for the full `cc` either
3073+
/// way, so this only removes redundant work.
3074+
///
3075+
/// Declining instead of waiting would not be neutral: the caller's cell is a
3076+
/// `OnceLock`, so a worker that gives up leaves that handle on Cranelift for
3077+
/// the rest of the process.
3078+
fn acquire_compile_lock(cache_dir: &Path, hash: &str, so_path: &Path) -> CompileTicket {
3079+
let path = cache_dir.join(format!("veryl_aot_{hash}.lock"));
3080+
loop {
3081+
match std::fs::OpenOptions::new()
3082+
.write(true)
3083+
.create_new(true)
3084+
.open(&path)
3085+
{
3086+
Ok(_) => return CompileTicket::Owned(CompileLock { path }),
3087+
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
3088+
Err(_) => return CompileTicket::Unlocked,
3089+
}
3090+
if so_path.exists() {
3091+
return CompileTicket::Published;
3092+
}
3093+
let dead = match owner_alive(&path) {
3094+
Some(alive) => !alive,
3095+
// Owner unknown (pid not written yet, or not unix): age it out.
3096+
None => std::fs::metadata(&path)
3097+
.and_then(|m| m.modified())
3098+
.map(|t| t.elapsed().unwrap_or_default() > COMPILE_LOCK_STALE)
3099+
// A lock we cannot stat is gone or unreadable; retrying the
3100+
// create resolves both.
3101+
.unwrap_or(true),
3102+
};
3103+
if dead {
3104+
if diag_enabled() {
3105+
eprintln!(
3106+
"[aot_c] taking over abandoned compile lock {}",
3107+
path.display()
3108+
);
3109+
}
3110+
let _ = std::fs::remove_file(&path);
3111+
continue;
3112+
}
3113+
std::thread::sleep(Duration::from_millis(250));
3114+
}
3115+
}
3116+
29723117
fn aot_c_cache_dir() -> Result<PathBuf, String> {
29733118
if let Ok(p) = std::env::var("VERYL_AOT_CACHE_DIR") {
29743119
return Ok(PathBuf::from(p));
@@ -5688,6 +5833,7 @@ mod tests {
56885833
ExpressionContext, ProtoAssignStatement, ProtoDynamicBitSelect, ProtoIfStatement,
56895834
ProtoSystemFunctionCall,
56905835
};
5836+
use std::time::Instant;
56915837
use veryl_analyzer::value::ValueU64;
56925838
use veryl_parser::token_range::TokenRange;
56935839

@@ -6492,6 +6638,119 @@ mod tests {
64926638
let _ = std::fs::remove_dir_all(&tmp);
64936639
}
64946640

6641+
#[test]
6642+
fn compile_lock_ticket_states() {
6643+
let tmp = std::env::temp_dir().join(format!("veryl_aot_lock_{}", std::process::id()));
6644+
std::fs::create_dir_all(&tmp).unwrap();
6645+
let so = tmp.join("veryl_aot_deadbeef.so");
6646+
let lock = tmp.join("veryl_aot_deadbeef.lock");
6647+
6648+
// Fresh: we own the lock, and the file exists while we hold it.
6649+
let ticket = acquire_compile_lock(&tmp, "deadbeef", &so);
6650+
assert!(matches!(ticket, CompileTicket::Owned(_)));
6651+
assert!(lock.exists());
6652+
6653+
// A second caller must not compile the same hash. It waits, so give
6654+
// it the exit the owner's publish provides. (The lock carries no pid
6655+
// yet, so the age rule keeps it alive — exactly the spawn window.)
6656+
std::fs::write(&so, b"not a real object").unwrap();
6657+
assert!(matches!(
6658+
acquire_compile_lock(&tmp, "deadbeef", &so),
6659+
CompileTicket::Published
6660+
));
6661+
6662+
std::fs::remove_file(&so).unwrap();
6663+
6664+
// Dropping the owner releases the lock even without the script's `rm`
6665+
// (the non-unix path and every early-return error path rely on this).
6666+
drop(ticket);
6667+
assert!(!lock.exists());
6668+
6669+
// A lock naming a dead owner is taken over at once rather than after
6670+
// the stale timeout: otherwise a run killed mid-compile would wedge
6671+
// this artifact out of the cache for ten minutes. Only unix can
6672+
// identify the owner; elsewhere `owner_alive` yields the age rule.
6673+
#[cfg(unix)]
6674+
{
6675+
// `kill -0 0` addresses our own process group, so name a pid that
6676+
// cannot exist rather than 0.
6677+
std::fs::write(&lock, format!("{}\n", u32::MAX)).unwrap();
6678+
assert_eq!(owner_alive(&lock), Some(false));
6679+
let retaken = acquire_compile_lock(&tmp, "deadbeef", &so);
6680+
assert!(matches!(retaken, CompileTicket::Owned(_)));
6681+
drop(retaken);
6682+
assert!(!lock.exists());
6683+
}
6684+
6685+
// An unusable lock directory degrades to compiling unlocked rather
6686+
// than failing the compile.
6687+
assert!(matches!(
6688+
acquire_compile_lock(
6689+
&tmp.join("no").join("such").join("dir"),
6690+
"deadbeef",
6691+
&tmp.join("no").join("such").join("dir").join("x.so"),
6692+
),
6693+
CompileTicket::Unlocked
6694+
));
6695+
6696+
let _ = std::fs::remove_dir_all(&tmp);
6697+
}
6698+
6699+
#[test]
6700+
fn compile_lock_serializes_identical_sources() {
6701+
// Concurrent callers of one hash: all must get a working module, the
6702+
// artifact must be published exactly once, and no lock may be left
6703+
// behind (a leaked lock would wedge the artifact until the stale
6704+
// takeover).
6705+
if !cc_available() {
6706+
eprintln!("compile_lock_serializes_identical_sources: cc unavailable, skipping");
6707+
return;
6708+
}
6709+
let src = "\
6710+
#include <stdint.h>\n\
6711+
__attribute__((visibility(\"default\")))\n\
6712+
void veryl_aot_eval(uint8_t *ff, uint8_t *comb, uint64_t *log, intptr_t ff_delta) {\n\
6713+
(void)ff; (void)log; (void)ff_delta;\n\
6714+
*(uint32_t*)(comb + 0) = 0x5a5a5a5a;\n\
6715+
}\n";
6716+
let tmp = std::env::temp_dir().join(format!("veryl_aot_lock_cc_{}", std::process::id()));
6717+
let _ = std::fs::remove_dir_all(&tmp);
6718+
std::fs::create_dir_all(&tmp).unwrap();
6719+
let mut handles = Vec::new();
6720+
for _ in 0..4 {
6721+
let dir = tmp.clone();
6722+
handles.push(std::thread::spawn(move || {
6723+
compile_source_in(&dir, src).map(|_| ())
6724+
}));
6725+
}
6726+
let mut skip = false;
6727+
for h in handles {
6728+
match h.join().unwrap() {
6729+
Ok(()) => {}
6730+
Err(e) if e.starts_with("dlopen") || e.starts_with("dlsym") => skip = true,
6731+
Err(e) => panic!("concurrent compile: {e}"),
6732+
}
6733+
}
6734+
if skip {
6735+
eprintln!("compile_lock_serializes_identical_sources: .so not loadable here; skipping");
6736+
let _ = std::fs::remove_dir_all(&tmp);
6737+
return;
6738+
}
6739+
let mut so = 0usize;
6740+
let mut locks = 0usize;
6741+
for e in std::fs::read_dir(&tmp).unwrap() {
6742+
let name = e.unwrap().file_name().to_string_lossy().into_owned();
6743+
if name.ends_with(".so") {
6744+
so += 1;
6745+
} else if name.ends_with(".lock") {
6746+
locks += 1;
6747+
}
6748+
}
6749+
assert_eq!(so, 1, "one artifact per hash");
6750+
assert_eq!(locks, 0, "the compile lock must be released");
6751+
let _ = std::fs::remove_dir_all(&tmp);
6752+
}
6753+
64956754
#[test]
64966755
fn emit_rhs_select_field_into_dst_select_rmw() {
64976756
// A per-bank extract: dst96[23:0] = (x700[174:0])[174:151] —
@@ -7685,7 +7944,7 @@ mod tests {
76857944
std::thread::spawn(move || {
76867945
let _ = compile_source_in(&dir, "// AOT-C publish probe\n");
76877946
});
7688-
std::thread::sleep(std::time::Duration::from_millis(200));
7947+
std::thread::sleep(Duration::from_millis(200));
76897948
std::process::exit(0);
76907949
}
76917950

@@ -7706,8 +7965,8 @@ mod tests {
77067965
.arg("aot_cache_publish_survives_process_exit")
77077966
.env(CHILD_DIR, &tmp)
77087967
.env("VERYL_AOT_CC", &slow_cc)
7709-
.stdout(std::process::Stdio::null())
7710-
.stderr(std::process::Stdio::null())
7968+
.stdout(Stdio::null())
7969+
.stderr(Stdio::null())
77117970
.status()
77127971
.unwrap();
77137972
assert!(status.success(), "child run failed");
@@ -7724,9 +7983,9 @@ mod tests {
77247983
n.starts_with("veryl_aot_") && n.ends_with(".so") && n.matches('.').count() == 1
77257984
})
77267985
};
7727-
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
7728-
while !published() && std::time::Instant::now() < deadline {
7729-
std::thread::sleep(std::time::Duration::from_millis(100));
7986+
let deadline = Instant::now() + Duration::from_secs(30);
7987+
while !published() && Instant::now() < deadline {
7988+
std::thread::sleep(Duration::from_millis(100));
77307989
}
77317990
assert!(published(), "the .so must publish after the run exits");
77327991
let _ = std::fs::remove_dir_all(&tmp);

0 commit comments

Comments
 (0)