1use std::collections::hash_map::DefaultHasher;
31use std::hash::{Hash, Hasher};
32use std::path::{Path, PathBuf};
33
34use crate::arch::SmVersion;
35use crate::error::PtxGenError;
36
37pub struct PtxCache {
43 cache_dir: PathBuf,
45}
46
47#[derive(Debug, Clone, Hash)]
52pub struct PtxCacheKey {
53 pub kernel_name: String,
55 pub params_hash: u64,
57 pub sm_version: SmVersion,
59}
60
61impl PtxCacheKey {
62 #[must_use]
72 pub fn to_filename(&self) -> String {
73 let mut hasher = DefaultHasher::new();
74 env!("CARGO_PKG_VERSION").hash(&mut hasher);
76 self.hash(&mut hasher);
77 let full_hash = hasher.finish();
78 format!(
79 "{}_{}_{:016x}.ptx",
80 sanitize_filename(&self.kernel_name),
81 self.sm_version.as_ptx_str(),
82 full_hash
83 )
84 }
85}
86
87impl PtxCache {
88 pub fn new() -> Result<Self, std::io::Error> {
97 let cache_dir = resolve_cache_dir()?;
98 std::fs::create_dir_all(&cache_dir)?;
99 Ok(Self { cache_dir })
100 }
101
102 pub fn with_dir(dir: PathBuf) -> Result<Self, std::io::Error> {
110 std::fs::create_dir_all(&dir)?;
111 Ok(Self { cache_dir: dir })
112 }
113
114 #[must_use]
116 pub const fn cache_dir(&self) -> &PathBuf {
117 &self.cache_dir
118 }
119
120 pub fn get_or_generate<F>(&self, key: &PtxCacheKey, generate: F) -> Result<String, PtxGenError>
132 where
133 F: FnOnce() -> Result<String, PtxGenError>,
134 {
135 let path = self.cache_dir.join(key.to_filename());
136
137 match std::fs::read_to_string(&path) {
139 Ok(contents) if !contents.is_empty() => return Ok(contents),
140 _ => {}
141 }
142
143 let ptx = generate()?;
145
146 if let Err(e) = atomic_write(&path, &ptx) {
150 eprintln!(
152 "oxicuda-ptx: cache write failed for {}: {e}",
153 path.display()
154 );
155 }
156
157 Ok(ptx)
158 }
159
160 #[must_use]
164 pub fn get(&self, key: &PtxCacheKey) -> Option<String> {
165 let path = self.cache_dir.join(key.to_filename());
166 match std::fs::read_to_string(&path) {
167 Ok(contents) if !contents.is_empty() => Some(contents),
168 _ => None,
169 }
170 }
171
172 pub fn put(&self, key: &PtxCacheKey, ptx: &str) -> Result<(), std::io::Error> {
178 let path = self.cache_dir.join(key.to_filename());
179 atomic_write(&path, ptx)
180 }
181
182 pub fn clear(&self) -> Result<(), std::io::Error> {
190 let entries = std::fs::read_dir(&self.cache_dir)?;
191 for entry in entries {
192 let entry = entry?;
193 let path = entry.path();
194 if path.extension().and_then(|e| e.to_str()) == Some("ptx") {
195 std::fs::remove_file(&path)?;
196 }
197 }
198 Ok(())
199 }
200
201 pub fn len(&self) -> Result<usize, std::io::Error> {
207 let entries = std::fs::read_dir(&self.cache_dir)?;
208 let count = entries
209 .filter_map(Result::ok)
210 .filter(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("ptx"))
211 .count();
212 Ok(count)
213 }
214
215 pub fn is_empty(&self) -> Result<bool, std::io::Error> {
221 self.len().map(|n| n == 0)
222 }
223}
224
225fn resolve_cache_dir() -> std::io::Result<PathBuf> {
243 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
245 let xdg = PathBuf::from(xdg);
246 if xdg.is_absolute() {
247 return Ok(xdg.join("oxicuda").join("ptx"));
248 }
249 }
250
251 if let Some(home) = home_dir() {
253 return Ok(home.join(".cache").join("oxicuda").join("ptx"));
254 }
255
256 temp_fallback_dir()
258}
259
260fn home_dir() -> Option<PathBuf> {
264 std::env::var_os("HOME")
265 .or_else(|| std::env::var_os("USERPROFILE"))
266 .map(PathBuf::from)
267}
268
269#[cfg(unix)]
274fn current_euid() -> Option<u32> {
275 use std::os::unix::fs::MetadataExt;
276
277 let nanos = std::time::SystemTime::now()
278 .duration_since(std::time::UNIX_EPOCH)
279 .map_or(0, |d| d.as_nanos());
280 let probe =
281 std::env::temp_dir().join(format!("oxicuda_uid_probe_{}_{nanos}", std::process::id()));
282 let uid = std::fs::OpenOptions::new()
283 .write(true)
284 .create_new(true)
285 .open(&probe)
286 .ok()
287 .and_then(|_| std::fs::symlink_metadata(&probe).ok())
288 .map(|m| m.uid());
289 let _ = std::fs::remove_file(&probe);
290 uid
291}
292
293#[cfg(unix)]
301fn temp_fallback_dir() -> std::io::Result<PathBuf> {
302 use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
303
304 let uid = current_euid().unwrap_or(0);
305 let dir = std::env::temp_dir().join(format!("oxicuda_ptx_cache_{uid}"));
306
307 std::fs::DirBuilder::new()
308 .recursive(true)
309 .mode(0o700)
310 .create(&dir)?;
311
312 let meta = std::fs::symlink_metadata(&dir)?;
314 if !meta.is_dir() {
315 return Err(std::io::Error::new(
316 std::io::ErrorKind::AlreadyExists,
317 "cache fallback path exists but is not a directory",
318 ));
319 }
320 if meta.uid() != uid {
321 return Err(std::io::Error::new(
322 std::io::ErrorKind::PermissionDenied,
323 "cache fallback directory is not owned by the current user",
324 ));
325 }
326 if meta.permissions().mode() & 0o022 != 0 {
327 return Err(std::io::Error::new(
328 std::io::ErrorKind::PermissionDenied,
329 "cache fallback directory is group- or world-writable",
330 ));
331 }
332 Ok(dir)
333}
334
335#[cfg(not(unix))]
338fn temp_fallback_dir() -> std::io::Result<PathBuf> {
339 Ok(std::env::temp_dir().join("oxicuda_ptx_cache"))
340}
341
342fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
356 use std::io::Write as _;
357
358 let dir = path.parent().ok_or_else(|| {
359 std::io::Error::new(
360 std::io::ErrorKind::InvalidInput,
361 "cache path has no parent directory",
362 )
363 })?;
364 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("cache");
365
366 let tmp = dir.join(format!(
369 ".{file_name}.{}.{:?}.tmp",
370 std::process::id(),
371 std::thread::current().id()
372 ));
373
374 let write_result = (|| -> std::io::Result<()> {
375 let mut file = std::fs::OpenOptions::new()
376 .write(true)
377 .create_new(true)
378 .open(&tmp)?;
379 file.write_all(contents.as_bytes())?;
380 file.sync_all()?;
381 Ok(())
382 })();
383
384 if let Err(e) = write_result {
385 let _ = std::fs::remove_file(&tmp);
386 return Err(e);
387 }
388
389 if let Err(e) = std::fs::rename(&tmp, path) {
390 let _ = std::fs::remove_file(&tmp);
391 return Err(e);
392 }
393 Ok(())
394}
395
396fn sanitize_filename(name: &str) -> String {
401 name.chars()
402 .map(|c| {
403 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
404 c
405 } else {
406 '_'
407 }
408 })
409 .collect()
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn test_cache_dir_named(name: &str) -> PathBuf {
418 std::env::temp_dir()
419 .join("oxicuda_ptx_cache_test")
420 .join(format!("{}_{}", name, std::process::id()))
421 }
422
423 fn cleanup(dir: &PathBuf) {
424 let _ = std::fs::remove_dir_all(dir);
425 }
426
427 #[test]
428 fn cache_key_to_filename() {
429 let key = PtxCacheKey {
430 kernel_name: "vector_add".to_string(),
431 params_hash: 0xDEAD_BEEF,
432 sm_version: SmVersion::Sm80,
433 };
434 let filename = key.to_filename();
435 assert!(filename.starts_with("vector_add_sm_80_"));
436 assert!(
437 std::path::Path::new(&filename)
438 .extension()
439 .is_some_and(|ext| ext.eq_ignore_ascii_case("ptx"))
440 );
441 }
442
443 #[test]
444 fn cache_key_sanitization() {
445 let key = PtxCacheKey {
446 kernel_name: "my.kernel/v2".to_string(),
447 params_hash: 42,
448 sm_version: SmVersion::Sm90,
449 };
450 let filename = key.to_filename();
451 assert!(
452 !filename.contains('.')
453 || std::path::Path::new(&filename)
454 .extension()
455 .is_some_and(|ext| ext.eq_ignore_ascii_case("ptx"))
456 );
457 let prefix = filename.split("_sm_90_").next().unwrap_or("");
459 assert!(!prefix.contains('/'));
460 }
461
462 #[test]
463 fn cache_new_and_clear() {
464 let dir = test_cache_dir_named("new_and_clear");
465 cleanup(&dir);
466
467 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
468 assert!(cache.is_empty().expect("should check empty"));
469
470 let key = PtxCacheKey {
471 kernel_name: "test".to_string(),
472 params_hash: 1,
473 sm_version: SmVersion::Sm80,
474 };
475 cache.put(&key, "// test ptx").expect("put should succeed");
476 assert!(!cache.is_empty().expect("should check non-empty"));
477 assert_eq!(cache.len().expect("len"), 1);
478
479 cache.clear().expect("clear should succeed");
480 assert!(cache.is_empty().expect("should be empty after clear"));
481
482 cleanup(&dir);
483 }
484
485 #[test]
486 fn get_or_generate_caches_result() {
487 let dir = test_cache_dir_named("get_or_generate");
488 cleanup(&dir);
489
490 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
491
492 let key = PtxCacheKey {
493 kernel_name: "cached_kernel".to_string(),
494 params_hash: 42,
495 sm_version: SmVersion::Sm80,
496 };
497
498 let mut call_count = 0u32;
499
500 let ptx1 = cache
502 .get_or_generate(&key, || {
503 call_count += 1;
504 Ok("// generated ptx v1".to_string())
505 })
506 .expect("should generate");
507 assert_eq!(ptx1, "// generated ptx v1");
508 assert_eq!(call_count, 1);
509
510 let ptx2 = cache
512 .get_or_generate(&key, || {
513 call_count += 1;
514 Ok("// should not be called".to_string())
515 })
516 .expect("should cache hit");
517 assert_eq!(ptx2, "// generated ptx v1");
518 assert_eq!(call_count, 1);
519
520 cleanup(&dir);
521 }
522
523 #[test]
524 fn get_nonexistent_returns_none() {
525 let dir = test_cache_dir_named("get_nonexistent");
526 cleanup(&dir);
527
528 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
529 let key = PtxCacheKey {
530 kernel_name: "nonexistent".to_string(),
531 params_hash: 0,
532 sm_version: SmVersion::Sm80,
533 };
534 assert!(cache.get(&key).is_none());
535
536 cleanup(&dir);
537 }
538
539 #[test]
540 fn sanitize_filename_fn() {
541 assert_eq!(sanitize_filename("hello_world"), "hello_world");
542 assert_eq!(sanitize_filename("foo.bar/baz"), "foo_bar_baz");
543 assert_eq!(sanitize_filename("a b c"), "a_b_c");
544 }
545
546 #[test]
552 fn test_cache_round_trip() {
553 let dir = test_cache_dir_named("round_trip");
554 cleanup(&dir);
555
556 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
557 let key = PtxCacheKey {
558 kernel_name: "round_trip_kernel".to_string(),
559 params_hash: 0xABCD_1234,
560 sm_version: SmVersion::Sm80,
561 };
562 let original = "// round-trip PTX content\n.version 8.0\n.target sm_80\n";
563
564 cache.put(&key, original).expect("put should succeed");
565 let retrieved = cache.get(&key).expect("get should return cached value");
566 assert_eq!(
567 original, retrieved,
568 "retrieved PTX must be identical to stored"
569 );
570
571 cleanup(&dir);
572 }
573
574 #[test]
576 fn test_cache_same_key_same_content() {
577 let dir = test_cache_dir_named("same_key");
578 cleanup(&dir);
579
580 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
581 let key = PtxCacheKey {
582 kernel_name: "stable_kernel".to_string(),
583 params_hash: 0x1111_2222,
584 sm_version: SmVersion::Sm90,
585 };
586 let ptx = "// stable content";
587
588 cache.put(&key, ptx).expect("first put should succeed");
589 let first = cache.get(&key).expect("first get should succeed");
590 let second = cache.get(&key).expect("second get should succeed");
591 assert_eq!(
592 first, second,
593 "same key must return identical content on repeated lookups"
594 );
595
596 cleanup(&dir);
597 }
598
599 #[test]
601 fn test_cache_different_keys() {
602 let dir = test_cache_dir_named("diff_keys");
603 cleanup(&dir);
604
605 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
606 let key_a = PtxCacheKey {
607 kernel_name: "kernel_a".to_string(),
608 params_hash: 0x0000_0001,
609 sm_version: SmVersion::Sm80,
610 };
611 let key_b = PtxCacheKey {
612 kernel_name: "kernel_b".to_string(),
613 params_hash: 0x0000_0002,
614 sm_version: SmVersion::Sm80,
615 };
616
617 cache
618 .put(&key_a, "// PTX for kernel A")
619 .expect("put A should succeed");
620 cache
621 .put(&key_b, "// PTX for kernel B")
622 .expect("put B should succeed");
623
624 let content_a = cache.get(&key_a).expect("get A should succeed");
625 let content_b = cache.get(&key_b).expect("get B should succeed");
626
627 assert_eq!(content_a, "// PTX for kernel A");
628 assert_eq!(content_b, "// PTX for kernel B");
629 assert_ne!(
630 content_a, content_b,
631 "different keys must retrieve different content"
632 );
633
634 cleanup(&dir);
635 }
636
637 #[test]
639 fn test_cache_hit_avoids_regeneration() {
640 let dir = test_cache_dir_named("hit_avoids_regen");
641 cleanup(&dir);
642
643 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
644 let key = PtxCacheKey {
645 kernel_name: "hit_kernel".to_string(),
646 params_hash: 0xCAFE_BABE,
647 sm_version: SmVersion::Sm80,
648 };
649
650 let mut call_count: u32 = 0;
651
652 let ptx_first = cache
654 .get_or_generate(&key, || {
655 call_count += 1;
656 Ok("// generated".to_string())
657 })
658 .expect("first generation should succeed");
659 assert_eq!(
660 call_count, 1,
661 "generation closure must be called on cache miss"
662 );
663
664 let ptx_second = cache
666 .get_or_generate(&key, || {
667 call_count += 1;
668 Ok("// should not be called".to_string())
669 })
670 .expect("second call should hit cache");
671 assert_eq!(
672 call_count, 1,
673 "generation closure must not be called on cache hit"
674 );
675 assert_eq!(
676 ptx_first, ptx_second,
677 "cache hit must return original content"
678 );
679
680 cleanup(&dir);
681 }
682
683 #[test]
686 fn test_atomic_write_no_torn_file() {
687 let dir = test_cache_dir_named("atomic_concurrent");
688 cleanup(&dir);
689 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
690
691 let key = PtxCacheKey {
692 kernel_name: "atomic_kernel".to_string(),
693 params_hash: 0x5151_5151,
694 sm_version: SmVersion::Sm80,
695 };
696
697 let payload_a = "// PAYLOAD_A ".repeat(4096);
699 let payload_b = "// PAYLOAD_B ".repeat(4096);
700
701 std::thread::scope(|scope| {
702 for _ in 0..8 {
703 let cache_ref = &cache;
704 let key_ref = &key;
705 let a = payload_a.as_str();
706 let b = payload_b.as_str();
707 scope.spawn(move || {
708 for i in 0..20 {
709 let payload = if i % 2 == 0 { a } else { b };
710 let _ = cache_ref.put(key_ref, payload);
711 }
712 });
713 }
714 });
715
716 let final_content = cache.get(&key).expect("a value must be present");
719 assert!(
720 final_content == payload_a || final_content == payload_b,
721 "cache file is torn: length {} is neither payload length ({} / {})",
722 final_content.len(),
723 payload_a.len(),
724 payload_b.len()
725 );
726
727 assert_eq!(
729 cache.len().expect("len"),
730 1,
731 "only the .ptx file should count"
732 );
733
734 cleanup(&dir);
735 }
736
737 #[test]
739 fn test_cache_miss_for_new_key() {
740 let dir = test_cache_dir_named("miss_new_key");
741 cleanup(&dir);
742
743 let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
744
745 let mut call_count: u32 = 0;
746
747 for i in 0u64..3 {
749 let key = PtxCacheKey {
750 kernel_name: format!("miss_kernel_{i}"),
751 params_hash: i,
752 sm_version: SmVersion::Sm80,
753 };
754 cache
755 .get_or_generate(&key, || {
756 call_count += 1;
757 Ok(format!("// ptx for key {i}"))
758 })
759 .expect("generation should succeed");
760 }
761
762 assert_eq!(
763 call_count, 3,
764 "each new key must trigger one generation call"
765 );
766
767 cleanup(&dir);
768 }
769}