mod llama_cpp;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::Serialize;
pub use llama_cpp::{
CUDA as LLAMA_CPP_CUDA, CUDART_ARM64_SHA256, CUDART_X64_SHA256,
GPU_GLIBC as LLAMA_CPP_GPU_GLIBC, MACOS_ARM64_SHA256, REPO_URL, ROCM as LLAMA_CPP_ROCM,
SOURCE_SHA256, TAG as LLAMA_CPP_TAG, UBUNTU_ARM64_SHA256, UBUNTU_CUDA_ARM64_SHA256,
UBUNTU_CUDA_X64_SHA256, UBUNTU_ROCM_X64_SHA256, UBUNTU_X64_SHA256, env as llama_cpp_env,
};
use crate::train::{
Artifacts, CONFIG_FILE, JobStage, METRICS_ENV, METRICS_FILE, OUTPUT_DIR, TrainError, Trainer,
top_level_scalar,
};
pub const EXPORT_SCRIPT: &str = concat!(include_str!("llama_cpp.sh"), include_str!("export.sh"));
pub const SCRIPT_FILE: &str = "export.sh";
pub const EXPORTS_DIR: &str = "exports";
pub const GGUF_DIR: &str = "output/gguf";
pub const EXPORT_FILE: &str = "export.json";
pub const MODELFILE: &str = "Modelfile";
pub(crate) const TRAMPOLINE: &str = "import os, sys; bin = os.path.dirname(sys.executable); \
os.environ['PATH'] = bin + os.pathsep + os.environ.get('PATH', ''); \
os.execvp('sh', ['sh'] + sys.argv[1:])";
pub(crate) const PYTHON: &str = "python3";
const NOT_STAGED: [&str; 2] = ["checkpoint-", "gguf"];
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
#[error("cannot access {}", path.display())]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("the export left no GGUF file in {}", .0.display())]
NoGguf(PathBuf),
}
fn io_error(path: &Path) -> impl FnOnce(io::Error) -> ExportError + '_ {
move |source| ExportError::Io {
path: path.to_path_buf(),
source,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportJob {
name: String,
quantize: String,
model: String,
config: String,
script: String,
stage: Option<(PathBuf, String)>,
}
impl ExportJob {
#[must_use]
pub fn in_job(name: &str, quantize: &str) -> Self {
Self {
name: name.to_string(),
quantize: quantize.to_string(),
model: OUTPUT_DIR.to_string(),
config: CONFIG_FILE.to_string(),
script: SCRIPT_FILE.to_string(),
stage: None,
}
}
#[must_use]
pub fn in_place(name: &str, quantize: &str, model: &str, script: String) -> Self {
Self {
name: name.to_string(),
quantize: quantize.to_string(),
model: format!("../../{model}"),
config: format!("../../{CONFIG_FILE}"),
script,
stage: None,
}
}
#[must_use]
pub fn staged(name: &str, quantize: &str, run_dir: &Path, model: &str) -> Self {
Self {
name: name.to_string(),
quantize: quantize.to_string(),
model: model.to_string(),
config: CONFIG_FILE.to_string(),
script: SCRIPT_FILE.to_string(),
stage: Some((run_dir.to_path_buf(), model.to_string())),
}
}
#[must_use]
pub fn file_name(&self) -> String {
gguf_name(&self.name, &self.quantize)
}
#[must_use]
pub fn quantize(&self) -> &str {
&self.quantize
}
#[must_use]
pub fn command(&self) -> Vec<String> {
vec![
PYTHON.to_string(),
"-c".to_string(),
TRAMPOLINE.to_string(),
self.script.clone(),
]
}
#[must_use]
pub fn script_env(&self) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = [
("OVERBRAINER_EXPORT_NAME", &self.name),
("OVERBRAINER_EXPORT_QUANTIZE", &self.quantize),
("OVERBRAINER_EXPORT_MODEL", &self.model),
("OVERBRAINER_EXPORT_CONFIG", &self.config),
]
.into_iter()
.map(|(name, value)| (name.to_string(), value.clone()))
.collect();
env.extend(llama_cpp::env());
env
}
pub fn write_script(dir: &Path) -> Result<(), TrainError> {
fs::create_dir_all(dir).map_err(train_io(dir))?;
let path = dir.join(SCRIPT_FILE);
fs::write(&path, EXPORT_SCRIPT).map_err(train_io(&path))
}
}
#[must_use]
pub fn gguf_name(name: &str, quantize: &str) -> String {
format!("{name}-{quantize}.gguf")
}
pub(crate) fn train_io(path: &Path) -> impl FnOnce(io::Error) -> TrainError + '_ {
move |source| TrainError::Io {
path: path.to_path_buf(),
source,
}
}
impl Trainer for ExportJob {
fn prepare(&self, run_dir: &Path, _root: &str) -> Result<(), TrainError> {
Self::write_script(run_dir)?;
let Some((from, model)) = &self.stage else {
return Ok(());
};
let config = from.join(CONFIG_FILE);
link_file(&config, &run_dir.join(CONFIG_FILE))?;
let output = from.join(OUTPUT_DIR);
let into = run_dir.join(OUTPUT_DIR);
if model == OUTPUT_DIR {
stage_output(&output, &into)
} else {
link_tree(&from.join(model), &run_dir.join(model))
}
}
fn commands(&self) -> Vec<Vec<String>> {
vec![self.command()]
}
fn env(&self, root: &str) -> Vec<(String, String)> {
let mut env = vec![(METRICS_ENV.to_string(), format!("{root}/{METRICS_FILE}"))];
env.extend(self.script_env());
env
}
fn metrics_file(&self) -> &'static str {
METRICS_FILE
}
fn artifacts(&self) -> Artifacts {
Artifacts {
entries: vec![GGUF_DIR.to_string(), METRICS_FILE.to_string()],
exclude: vec!["*.part".to_string()],
required: Some(GGUF_DIR.to_string()),
}
}
fn stages(&self) -> Vec<JobStage> {
vec![JobStage::Export]
}
fn metrics_required(&self) -> bool {
false
}
fn caches_tools(&self) -> bool {
true
}
}
fn stage_output(output: &Path, into: &Path) -> Result<(), TrainError> {
fs::create_dir_all(into).map_err(train_io(into))?;
for entry in fs::read_dir(output).map_err(train_io(output))? {
let entry = entry.map_err(train_io(output))?;
let name = entry.file_name().to_string_lossy().into_owned();
if NOT_STAGED.iter().any(|skip| name.starts_with(skip)) {
continue;
}
let kind = entry.file_type().map_err(train_io(&entry.path()))?;
let target = into.join(&name);
if kind.is_dir() {
link_tree(&entry.path(), &target)?;
} else if kind.is_file() {
link_file(&entry.path(), &target)?;
}
}
Ok(())
}
fn link_tree(from: &Path, to: &Path) -> Result<(), TrainError> {
fs::create_dir_all(to).map_err(train_io(to))?;
for entry in fs::read_dir(from).map_err(train_io(from))? {
let entry = entry.map_err(train_io(from))?;
let kind = entry.file_type().map_err(train_io(&entry.path()))?;
let target = to.join(entry.file_name());
if kind.is_dir() {
link_tree(&entry.path(), &target)?;
} else if kind.is_file() {
link_file(&entry.path(), &target)?;
}
}
Ok(())
}
pub(crate) fn link_file(from: &Path, to: &Path) -> Result<(), TrainError> {
if let Some(dir) = to.parent() {
fs::create_dir_all(dir).map_err(train_io(dir))?;
}
match fs::remove_file(to) {
Ok(()) => {},
Err(error) if error.kind() == io::ErrorKind::NotFound => {},
Err(error) => return Err(train_io(to)(error)),
}
if fs::hard_link(from, to).is_ok() {
return Ok(());
}
fs::copy(from, to)
.map(drop)
.map_err(|source| TrainError::Copy {
from: from.to_path_buf(),
to: to.to_path_buf(),
source,
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct ExportRecord {
pub quantize: String,
pub llama_cpp: String,
pub file: String,
pub sha256: String,
pub size: u64,
pub created: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delivered {
pub gguf: PathBuf,
pub modelfile: PathBuf,
pub record: ExportRecord,
}
pub fn deliver(run_dir: &Path, job_dir: &Path, file: &str) -> Result<Delivered, ExportError> {
let gguf_dir = run_dir.join(GGUF_DIR);
let gguf = gguf_dir.join(file);
if job_dir != run_dir {
let made = job_dir.join(GGUF_DIR).join(file);
if !made.is_file() {
return Err(ExportError::NoGguf(job_dir.join(GGUF_DIR)));
}
fs::create_dir_all(&gguf_dir).map_err(io_error(&gguf_dir))?;
fs::rename(&made, &gguf).map_err(io_error(&made))?;
discard_staged(run_dir, job_dir)?;
} else if !gguf.is_file() {
return Err(ExportError::NoGguf(gguf_dir));
}
let size = fs::metadata(&gguf).map_err(io_error(&gguf))?.len();
let sha256 = crate::exec::sha256_file(&gguf).map_err(io_error(&gguf))?;
let quantize = quantize_of(file).unwrap_or_default();
let record = ExportRecord {
quantize,
llama_cpp: LLAMA_CPP_TAG.to_string(),
file: format!("{GGUF_DIR}/{file}"),
sha256,
size,
created: crate::runs::rfc3339(SystemTime::now()),
};
let json = job_dir.join(EXPORT_FILE);
let mut text = serde_json::to_string_pretty(&record).map_err(|error| ExportError::Io {
path: json.clone(),
source: io::Error::other(error),
})?;
text.push('\n');
fs::write(&json, text).map_err(io_error(&json))?;
let modelfile = gguf_dir.join(MODELFILE);
let context = sequence_len(&run_dir.join(CONFIG_FILE));
fs::write(&modelfile, modelfile_text(file, context)).map_err(io_error(&modelfile))?;
Ok(Delivered {
gguf,
modelfile,
record,
})
}
#[must_use]
pub fn latest_gguf(run_dir: &Path, name: &str) -> Option<String> {
let entries = fs::read_dir(run_dir.join(GGUF_DIR)).ok()?;
entries
.filter_map(Result::ok)
.filter_map(|entry| {
let file = entry.file_name().to_string_lossy().into_owned();
let quantize = file
.strip_prefix(&format!("{name}-"))
.and_then(|rest| rest.strip_suffix(".gguf"))?;
if !crate::config::QUANTIZE_TYPES.contains(&quantize) {
return None;
}
let modified = entry.metadata().ok()?.modified().ok()?;
Some((modified, file))
})
.max()
.map(|(_, file)| file)
}
fn quantize_of(file: &str) -> Option<String> {
let stem = file.strip_suffix(".gguf")?;
crate::config::QUANTIZE_TYPES
.iter()
.find(|quantize| stem.ends_with(&format!("-{quantize}")))
.map(|quantize| (*quantize).to_string())
}
pub fn discard_staged(run_dir: &Path, job_dir: &Path) -> Result<(), ExportError> {
if job_dir == run_dir {
return Ok(());
}
for staged in [job_dir.join(OUTPUT_DIR), job_dir.join(CONFIG_FILE)] {
remove_any(&staged)?;
}
Ok(())
}
fn remove_any(path: &Path) -> Result<(), ExportError> {
let removed = if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
match removed {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_error(path)(error)),
}
}
pub(crate) fn sequence_len(path: &Path) -> Option<u32> {
let text = fs::read_to_string(path).ok()?;
top_level_scalar(&text, "sequence_len")?.parse().ok()
}
#[must_use]
pub fn modelfile_text(file: &str, num_ctx: Option<u32>) -> String {
let mut text = format!("FROM ./{file}\n");
if let Some(num_ctx) = num_ctx {
let _ = writeln!(text, "PARAMETER num_ctx {num_ctx}");
}
text
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ollama {
Created,
NotFound(String),
Failed(String),
}
#[must_use]
pub fn ollama_command(gguf_dir: &Path, name: &str) -> String {
format!(
"cd {} && ollama create {name} -f {MODELFILE}",
crate::exec::quote(&gguf_dir.to_string_lossy())
)
}
#[must_use]
pub fn ollama_create(program: &str, gguf_dir: &Path, name: &str) -> Ollama {
let output = std::process::Command::new(program)
.args(["create", name, "-f", MODELFILE])
.current_dir(gguf_dir)
.stdin(std::process::Stdio::null())
.output();
match output {
Ok(output) if output.status.success() => Ollama::Created,
Ok(output) => {
let error = String::from_utf8_lossy(&output.stderr);
let last = error
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.map_or_else(|| output.status.to_string(), str::to_string);
Ollama::Failed(last)
},
Err(error) if error.kind() == io::ErrorKind::NotFound => {
Ollama::NotFound(ollama_command(gguf_dir, name))
},
Err(error) => Ollama::Failed(error.to_string()),
}
}
#[cfg(test)]
mod tests;