use anyhow::{Result, ensure};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Source {
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rows: Option<usize>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Storage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_access_key: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub virtual_hosted_style_request: bool,
}
impl Storage {
pub fn without_credentials(&self) -> Self {
Self {
access_key_id: None,
secret_access_key: None,
..self.clone()
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub name: String,
pub from: String,
pub dtype: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pad: Option<serde_json::Value>,
}
impl Field {
pub fn dimensions(&self) -> Vec<usize> {
self.shape
.clone()
.unwrap_or_else(|| self.length.into_iter().collect())
}
pub fn width(&self) -> usize {
self.dimensions().iter().product()
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Group {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encoding: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Vec<Field>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<Source>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub split: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curriculum_stages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight: Option<f64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Shuffle {
pub buffer_rows: usize,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Stage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encoding: Option<String>,
pub sources: Vec<Group>,
pub batch_size: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shuffle: Option<Shuffle>,
pub infinite: bool,
#[serde(default)]
pub drop_last: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub storage: Storage,
pub stages: Vec<Stage>,
pub fields: Vec<Field>,
pub seed: u64,
pub rank: u64,
pub world_size: u64,
pub threads: usize,
pub prefetch_batches: usize,
#[serde(default = "lookahead")]
pub lookahead: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_limit_bytes: Option<u64>,
#[serde(default = "record_limit")]
pub max_record_bytes: usize,
#[serde(default = "row_group_limit")]
pub max_row_group_bytes: usize,
#[serde(default = "row_limit")]
pub max_shard_rows: usize,
}
fn lookahead() -> usize {
2
}
fn record_limit() -> usize {
4 * 1024 * 1024
}
fn row_group_limit() -> usize {
64 * 1024 * 1024
}
fn row_limit() -> usize {
65536
}
impl Config {
pub fn memory_limit(&self) -> Result<u64> {
if let Some(limit) = self.memory_limit_bytes {
return Ok(limit);
}
let pages = unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) };
let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
ensure!(
pages > 0 && page_size > 0,
"cannot read host physical memory"
);
Ok(((pages as u128 * page_size as u128) / 2).try_into()?)
}
pub fn memory_bound(&self) -> Result<u64> {
let groups: Vec<_> = self.stages.iter().flat_map(|s| &s.sources).collect();
let row: u128 = self
.fields
.iter()
.map(|f| {
f.width() as u128
* if matches!(f.dtype.as_str(), "i64" | "f64") {
8
} else {
4
}
})
.sum();
let metadata = std::iter::once(&self.fields)
.chain(groups.iter().filter_map(|g| g.fields.as_ref()))
.map(|fields| {
fields
.iter()
.map(|f| (4096 + f.name.len() + f.from.len()) as u128)
.sum::<u128>()
})
.max()
.unwrap_or(0);
let rows = groups
.iter()
.flat_map(|g| &g.files)
.map(|f| f.rows.unwrap_or(self.max_shard_rows))
.max()
.unwrap_or(0) as u128;
let batch_rows = self.stages.iter().map(|s| s.batch_size).max().unwrap_or(0) as u128;
let batch = (row + 128) * batch_rows + metadata;
let shuffle = self
.stages
.iter()
.filter_map(|s| s.shuffle.as_ref())
.map(|s| s.buffer_rows)
.max()
.unwrap_or(0) as u128;
let sources = self
.stages
.iter()
.map(|s| s.sources.len())
.max()
.unwrap_or(0) as u128;
let parquet = self.stages.iter().any(|s| {
s.sources.iter().any(|g| {
g.encoding
.as_ref()
.or(s.encoding.as_ref())
.is_some_and(|e| e == "parquet")
})
});
let shard = row * rows;
let fetched = crate::storage::HELD as u128
* if parquet {
(self.max_row_group_bytes as u64).max(crate::storage::WINDOW) as u128
} else {
crate::storage::WINDOW as u128
}
+ crate::storage::PARTS as u128;
let workspace = (64 * self.max_record_bytes as u128).max(if parquet {
8 * self.max_row_group_bytes as u128
} else {
0
}) + 16 * row * rows.min(128)
+ 2 * shard
+ metadata * rows.div_ceil(128)
+ fetched
+ 32 * 1024 * 1024;
let buffered = (shuffle + batch_rows + 1) * (row + 1024);
let snapshots = 64 * (shuffle + sources);
let bound = (self.threads as u128)
.checked_mul(self.lookahead as u128)
.and_then(|n| n.checked_mul(shard + metadata))
.and_then(|n| {
(self.threads as u128)
.checked_mul(workspace)
.and_then(|w| n.checked_add(w))
})
.and_then(|n| n.checked_add(buffered))
.and_then(|n| {
(self.prefetch_batches as u128 + 3)
.checked_mul(batch * 3 + snapshots)
.and_then(|q| n.checked_add(q))
})
.ok_or_else(|| anyhow::anyhow!("memory bound exceeds supported range"))?;
Ok(bound.try_into()?)
}
pub fn validate(&self) -> Result<u64> {
ensure!(!self.stages.is_empty(), "no stages");
ensure!(
!self.fields.is_empty(),
"fields must be a nonempty allowlist"
);
ensure!(
self.lookahead > 0
&& self.max_record_bytes > 0
&& self.max_shard_rows > 0
&& self.max_row_group_bytes > 0,
"invalid memory limits"
);
ensure!(
self.world_size > 0 && self.rank < self.world_size,
"invalid rank layout"
);
ensure!(self.threads > 0, "threads must be positive");
for (stage_index, stage) in self.stages.iter().enumerate() {
ensure!(
stage.sources.len() <= self.threads.saturating_mul(self.lookahead),
"threads * lookahead must hold one current shard per source"
);
ensure!(
stage.batch_size > 0 && !stage.sources.is_empty(),
"empty stage"
);
ensure!(
stage.shuffle.as_ref().is_none_or(|s| s.buffer_rows > 0),
"empty shuffle buffer"
);
let mut sum = 0.0;
for (source_index, group) in stage.sources.iter().enumerate() {
ensure!(
group.manifest_uri.is_none() && group.split.is_none(),
"stage {stage_index} source {source_index}: manifest source is not resolved"
);
ensure!(
matches!(
group
.encoding
.as_ref()
.or(stage.encoding.as_ref())
.map(String::as_str),
Some("msgpack.zstf" | "jsonl.gz" | "parquet")
),
"stage {stage_index} source {source_index}: unknown encoding"
);
let w = group.weight.unwrap_or(1.0);
ensure!(
w.is_finite() && w >= 0.0 && !group.files.is_empty(),
"stage {stage_index} source {source_index}: invalid source group"
);
ensure!(
group
.files
.iter()
.all(|f| f.rows.is_none_or(|n| n <= self.max_shard_rows)),
"manifest rows exceed maxShardRows"
);
sum += w;
}
ensure!(sum.is_finite() && sum > 0.0, "invalid total weight");
}
for fields in std::iter::once(&self.fields).chain(
self.stages
.iter()
.flat_map(|s| &s.sources)
.filter_map(|g| g.fields.as_ref()),
) {
let mut names = std::collections::HashSet::new();
for field in fields {
ensure!(names.insert(&field.name), "duplicate projected name");
ensure!(
matches!(field.dtype.as_str(), "i32" | "i64" | "f32" | "f64" | "bool"),
"unknown dtype"
);
ensure!(
field.length.is_none_or(|n| n > 0 && n <= i32::MAX as usize),
"invalid field length"
);
ensure!(
field.shape.is_none() || field.length.is_none(),
"use length or shape, not both"
);
if let Some(shape) = &field.shape {
ensure!(
!shape.is_empty() && shape.iter().all(|&n| n > 0),
"invalid field shape"
);
ensure!(
shape
.iter()
.try_fold(1usize, |a, &b| a.checked_mul(b))
.is_some_and(|n| n <= i32::MAX as usize),
"field shape is too large"
);
}
}
}
for (stage_index, stage) in self.stages.iter().enumerate() {
for (source_index, source) in stage.sources.iter().enumerate() {
let fields = source.fields.as_ref().unwrap_or(&self.fields);
ensure!(
fields.len() == self.fields.len(),
"stage {stage_index} source {source_index} {}: field count differs",
source.files[0].uri
);
for (a, b) in fields.iter().zip(&self.fields) {
ensure!(
a.name == b.name && a.dtype == b.dtype && a.dimensions() == b.dimensions(),
"stage {stage_index} source {source_index} {}: incompatible field {}",
source.files[0].uri,
b.name
);
}
}
}
let bound = self.memory_bound()?;
let limit = self.memory_limit()?;
ensure!(
bound <= limit,
"memory bound {bound} bytes exceeds memoryLimitBytes {limit}"
);
Ok(bound)
}
}