Skip to main content

o41_training_data/
engine.rs

1use crate::{config::Config, readers};
2use anyhow::{Result, ensure};
3use arrow::record_batch::RecordBatch;
4use crossbeam_channel::{Receiver, bounded};
5use serde::{Deserialize, Serialize};
6use std::{
7    collections::{HashMap, HashSet},
8    sync::Arc,
9};
10
11#[derive(Clone, Debug, Default, Serialize, Deserialize)]
12pub struct Position {
13    pub file: usize,
14    pub row: usize,
15    pub epoch: u64,
16    pub done: bool,
17}
18#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct Coordinate {
20    pub stage: usize,
21    pub source: usize,
22    pub file: usize,
23    pub row: usize,
24    pub epoch: u64,
25}
26#[derive(Clone, Debug, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct State {
29    pub version: u32,
30    pub stage: usize,
31    pub batch_index: u64,
32    pub batches_yielded: u64,
33    pub rows_yielded: u64,
34    pub positions: Vec<Position>,
35    pub mix_counter: u64,
36    pub shuffle_counter: u64,
37    pub buffer: Vec<Coordinate>,
38    pub partial: Vec<Coordinate>,
39}
40impl State {
41    pub fn new(config: &Config, stage: usize) -> Self {
42        Self {
43            version: 3,
44            stage,
45            batch_index: 0,
46            batches_yielded: 0,
47            rows_yielded: 0,
48            positions: vec![Position::default(); config.stages[stage].sources.len()],
49            mix_counter: 0,
50            shuffle_counter: 0,
51            buffer: Vec::new(),
52            partial: Vec::new(),
53        }
54    }
55    pub fn validate(&self, config: &Config) -> Result<()> {
56        ensure!(
57            self.version == 3 && self.stage < config.stages.len(),
58            "unsupported cursor version or stage"
59        );
60        let stage = &config.stages[self.stage];
61        ensure!(
62            self.positions.len() == stage.sources.len(),
63            "cursor source count mismatch"
64        );
65        for (p, g) in self.positions.iter().zip(&stage.sources) {
66            ensure!(p.file <= g.files.len(), "cursor file out of bounds");
67        }
68        ensure!(
69            self.partial.len() <= stage.batch_size,
70            "cursor partial batch too large"
71        );
72        for c in self.buffer.iter().chain(&self.partial) {
73            ensure!(
74                c.stage < config.stages.len(),
75                "cursor coordinate stage out of bounds"
76            );
77            let stage = &config.stages[c.stage];
78            ensure!(
79                c.source < stage.sources.len() && c.file < stage.sources[c.source].files.len(),
80                "cursor coordinate out of bounds"
81            );
82        }
83        Ok(())
84    }
85}
86// UTF-8 names "mix" and "shuffle". All operations wrap at 64 bits.
87// Stage keys are avalanched before combining, avoiding adjacent-counter shifts.
88fn mix64(mut x: u64) -> u64 {
89    x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
90    x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
91    x ^ (x >> 31)
92}
93pub fn random(seed: u64, stage: usize, name: &str, source: u64, counter: u64) -> u64 {
94    let mut key = 14695981039346656037u64;
95    for b in name.bytes() {
96        key = (key ^ b as u64).wrapping_mul(1099511628211);
97    }
98    let golden = 0x9e3779b97f4a7c15u64;
99    key = key.wrapping_add(seed)
100        ^ mix64((stage as u64).wrapping_add(1).wrapping_mul(golden))
101        ^ mix64(source.wrapping_mul(golden));
102    mix64(key.wrapping_add(counter.wrapping_add(1).wrapping_mul(golden)))
103}
104type Key = (usize, usize, usize);
105pub struct Engine {
106    pub config: Arc<Config>,
107    pub state: State,
108    cache: HashMap<Key, readers::Shard>,
109    pending: HashMap<Key, Receiver<Result<readers::Shard>>>,
110    rows: HashMap<Coordinate, RecordBatch>,
111    orders: Vec<Vec<Vec<usize>>>,
112    window: Option<Key>,
113    pool: rayon::ThreadPool,
114    client: Arc<crate::storage::Client>,
115}
116pub struct Batch {
117    pub columns: RecordBatch,
118    pub stage: usize,
119    pub index: u64,
120    pub state: State,
121}
122impl Engine {
123    pub fn new(config: Arc<Config>, state: State) -> Result<Self> {
124        let pool = rayon::ThreadPoolBuilder::new()
125            .num_threads(config.threads)
126            .build()?;
127        let runtime = Arc::new(
128            tokio::runtime::Builder::new_multi_thread()
129                .worker_threads(1)
130                .enable_all()
131                .build()?,
132        );
133        let client = Arc::new(crate::storage::Client::new(config.storage.clone(), runtime));
134        for (stage_index, stage) in config.stages.iter().enumerate() {
135            for (source_index, source) in stage.sources.iter().enumerate() {
136                let fields = source.fields.as_deref().unwrap_or(&config.fields);
137                let encoding = source
138                    .encoding
139                    .as_ref()
140                    .or(stage.encoding.as_ref())
141                    .unwrap();
142                let mut found = false;
143                for file in &source.files {
144                    use anyhow::Context;
145                    let sample = readers::read(file, encoding, &client, fields, 0, true, &config)
146                        .with_context(|| {
147                        format!("stage={stage_index} source={source_index}")
148                    })?;
149                    if sample.columns.num_rows() > 0 {
150                        found = true;
151                        break;
152                    }
153                }
154                ensure!(
155                    found,
156                    "stage {stage_index} source {source_index} {}: no record for field {}",
157                    source.files[0].uri,
158                    fields[0].name
159                );
160            }
161        }
162        let orders = config
163            .stages
164            .iter()
165            .enumerate()
166            .map(|(stage_index, stage)| {
167                stage
168                    .sources
169                    .iter()
170                    .enumerate()
171                    .map(|(source_index, source)| {
172                        let mut order: Vec<_> = (0..source.files.len()).collect();
173                        if stage.shuffle.is_some() {
174                            for (draw, i) in (1..order.len()).rev().enumerate() {
175                                let j = random(
176                                    config.seed,
177                                    stage_index,
178                                    "shards",
179                                    source_index as u64,
180                                    draw as u64,
181                                ) % (i as u64 + 1);
182                                order.swap(i, j as usize);
183                            }
184                        }
185                        order
186                    })
187                    .collect()
188            })
189            .collect();
190        let mut engine = Self {
191            config,
192            state,
193            cache: HashMap::new(),
194            pending: HashMap::new(),
195            rows: HashMap::new(),
196            orders,
197            window: None,
198            pool,
199            client,
200        };
201        engine.restore_rows()?;
202        Ok(engine)
203    }
204    fn heads(&self) -> impl Iterator<Item = Key> + '_ {
205        self.state
206            .positions
207            .iter()
208            .enumerate()
209            .filter(|(_, p)| !p.done)
210            .map(|(source, p)| {
211                (
212                    self.state.stage,
213                    source,
214                    self.orders[self.state.stage][source][p.file],
215                )
216            })
217    }
218    fn schedule(
219        &mut self,
220        stage_index: usize,
221        source: usize,
222        file: usize,
223        row: usize,
224        required: bool,
225    ) {
226        let key = (stage_index, source, file);
227        if self.cache.contains_key(&key) || self.pending.contains_key(&key) {
228            return;
229        }
230        // Future shards cannot take the slots needed by other sources' current shards.
231        let reserved = if required {
232            0
233        } else {
234            self.heads()
235                .filter(|k| !self.cache.contains_key(k) && !self.pending.contains_key(k))
236                .count()
237        };
238        if self.cache.len() + self.pending.len() + reserved
239            >= self.config.threads * self.config.lookahead
240        {
241            return;
242        }
243        let config = self.config.clone();
244        let client = self.client.clone();
245        let (send, recv) = bounded(1);
246        self.pool.spawn(move || {
247            let stage = &config.stages[stage_index];
248            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
249                readers::read(
250                    &stage.sources[source].files[file],
251                    stage.sources[source]
252                        .encoding
253                        .as_ref()
254                        .or(stage.encoding.as_ref())
255                        .unwrap(),
256                    &client,
257                    stage.sources[source]
258                        .fields
259                        .as_deref()
260                        .unwrap_or(&config.fields),
261                    row,
262                    false,
263                    &config,
264                )
265            }))
266            .unwrap_or_else(|_| Err(anyhow::anyhow!("shard reader panicked")));
267            let result = result
268                .map_err(|error| anyhow::anyhow!("stage={stage_index} source={source}: {error:#}"));
269            let _ = send.send(result);
270        });
271        self.pending.insert(key, recv);
272    }
273    fn lookahead(&mut self, source: usize, file: usize) {
274        if self.window == Some((self.state.stage, source, file)) {
275            return;
276        }
277        self.window = Some((self.state.stage, source, file));
278        let stage = &self.config.stages[self.state.stage];
279        let (count, infinite) = (stage.sources[source].files.len(), stage.infinite);
280        // At least the next shard, so one thread reads it during the current;
281        // an infinite source continues into its next epoch.
282        let window = self.config.threads.max(2);
283        let ahead = if infinite {
284            window.min(count)
285        } else {
286            window.min(count - file)
287        };
288        for k in 0..ahead {
289            self.schedule(
290                self.state.stage,
291                source,
292                self.orders[self.state.stage][source][(file + k) % count],
293                if k == 0 {
294                    self.state.positions[source].row
295                } else {
296                    0
297                },
298                k == 0,
299            );
300        }
301    }
302    fn load(&mut self, stage_index: usize, source: usize, file: usize, row: usize) -> Result<()> {
303        let key = (stage_index, source, file);
304        if self.cache.get(&key).is_some_and(|s| s.offset <= row) {
305            return Ok(());
306        }
307        self.cache.remove(&key);
308        if !self.pending.contains_key(&key) {
309            ensure!(
310                self.cache.len() + self.pending.len() < self.config.threads * self.config.lookahead,
311                "reader slots exhausted"
312            );
313            self.schedule(stage_index, source, file, row, true);
314        }
315        let wait = crate::profile::Timer::new(7);
316        let result = self
317            .pending
318            .remove(&key)
319            .unwrap()
320            .recv()
321            .map_err(|_| anyhow::anyhow!("shard worker stopped"))??;
322        drop(wait);
323        if result.offset > row {
324            return self.load(stage_index, source, file, row);
325        }
326        self.cache.insert(key, result);
327        Ok(())
328    }
329    fn retain_row(&mut self, c: Coordinate) -> Result<()> {
330        let shard = &self.cache[&(c.stage, c.source, c.file)];
331        // A slice would retain the entire shard. Copy just the projected row.
332        let row = arrow::compute::interleave_record_batch(
333            &[&shard.columns],
334            &[(0, c.row - shard.offset)],
335        )?;
336        self.rows.insert(c, row);
337        Ok(())
338    }
339    fn restore_rows(&mut self) -> Result<()> {
340        let mut groups = std::collections::BTreeMap::<Key, Vec<Coordinate>>::new();
341        for c in self.state.buffer.iter().chain(&self.state.partial) {
342            groups
343                .entry((c.stage, c.source, c.file))
344                .or_default()
345                .push(c.clone());
346        }
347        let heads: HashSet<_> = self.heads().collect();
348        let mut groups: Vec<_> = groups.into_iter().collect();
349        // Restore old shards first; retain current shards for their unread suffixes.
350        groups.sort_by_key(|(key, _)| heads.contains(key));
351        for (key, coordinates) in groups {
352            let mut start = coordinates.iter().map(|c| c.row).min().unwrap();
353            if heads.contains(&key) {
354                start = start.min(self.state.positions[key.1].row);
355            }
356            self.load(key.0, key.1, key.2, start)?;
357            for c in coordinates {
358                self.retain_row(c)?;
359            }
360            if !heads.contains(&key) {
361                self.cache.remove(&key);
362            }
363        }
364        Ok(())
365    }
366    fn source_next(&mut self, source: usize) -> Result<Option<Coordinate>> {
367        let stage = self.state.stage;
368        let count = self.orders[stage][source].len();
369        let infinite = self.config.stages[stage].infinite;
370        let mut empty = 0;
371        loop {
372            let p = &self.state.positions[source];
373            if p.done {
374                return Ok(None);
375            }
376            let (position, row, epoch) = (p.file, p.row, p.epoch);
377            let file = self.orders[stage][source][position];
378            self.lookahead(source, position);
379            self.load(stage, source, file, row)?;
380            let end = self.cache[&(stage, source, file)].num_rows();
381            let result = if row < end {
382                let c = Coordinate {
383                    stage,
384                    source,
385                    file,
386                    row,
387                    epoch,
388                };
389                self.retain_row(c.clone())?;
390                Some(c)
391            } else {
392                None
393            };
394            let p = &mut self.state.positions[source];
395            p.row += usize::from(result.is_some());
396            if p.row >= end {
397                self.cache.remove(&(stage, source, file));
398                p.row = 0;
399                p.file += 1;
400                if p.file == count {
401                    p.epoch += 1;
402                    if infinite {
403                        p.file = 0;
404                    } else {
405                        p.done = true;
406                    }
407                }
408            }
409            if result.is_some() {
410                return Ok(result);
411            }
412            empty += 1;
413            ensure!(
414                !infinite || empty <= count,
415                "infinite source has no records"
416            );
417        }
418    }
419    fn mixed(&mut self) -> Result<Option<Coordinate>> {
420        loop {
421            let groups = &self.config.stages[self.state.stage].sources;
422            let total: f64 = groups
423                .iter()
424                .zip(&self.state.positions)
425                .filter(|(_, p)| !p.done)
426                .map(|(g, _)| g.weight.unwrap_or(1.0))
427                .sum();
428            if total == 0.0 {
429                return Ok(None);
430            }
431            let mut chosen = 0;
432            if groups.len() > 1 {
433                let draw = (random(
434                    self.config.seed,
435                    self.state.stage,
436                    "mix",
437                    0,
438                    self.state.mix_counter,
439                ) >> 11) as f64
440                    * (1.0 / ((1u64 << 53) as f64));
441                self.state.mix_counter = self.state.mix_counter.wrapping_add(1);
442                let mut target = draw * total;
443                for (i, (g, p)) in groups.iter().zip(&self.state.positions).enumerate() {
444                    if p.done || g.weight.unwrap_or(1.0) == 0.0 {
445                        continue;
446                    }
447                    chosen = i;
448                    target -= g.weight.unwrap_or(1.0);
449                    if target < 0.0 {
450                        break;
451                    }
452                }
453            }
454            if let Some(c) = self.source_next(chosen)? {
455                return Ok(Some(c));
456            }
457        }
458    }
459    fn coordinate(&mut self) -> Result<Option<Coordinate>> {
460        let capacity = self.config.stages[self.state.stage]
461            .shuffle
462            .as_ref()
463            .map_or(0, |s| s.buffer_rows);
464        if capacity == 0 && self.state.buffer.is_empty() {
465            return self.mixed();
466        }
467        while self.state.buffer.len() < capacity {
468            if let Some(c) = self.mixed()? {
469                self.state.buffer.push(c);
470            } else {
471                break;
472            }
473        }
474        if self.state.buffer.is_empty() {
475            return Ok(None);
476        }
477        let i = (random(
478            self.config.seed,
479            self.state.stage,
480            "shuffle",
481            0,
482            self.state.shuffle_counter,
483        ) % self.state.buffer.len() as u64) as usize;
484        self.state.shuffle_counter = self.state.shuffle_counter.wrapping_add(1);
485        let replacement = if self.state.buffer.len() <= capacity {
486            self.mixed()?
487        } else {
488            None
489        };
490        let result = if let Some(c) = replacement {
491            std::mem::replace(&mut self.state.buffer[i], c)
492        } else {
493            self.state.buffer.swap_remove(i)
494        };
495        Ok(Some(result))
496    }
497    fn retire(&mut self) {
498        let keep: HashSet<_> = self
499            .state
500            .buffer
501            .iter()
502            .chain(&self.state.partial)
503            .collect();
504        self.rows.retain(|c, _| keep.contains(c));
505    }
506    fn materialize(&self, coordinates: &[Coordinate]) -> Result<RecordBatch> {
507        let _assembly = crate::profile::Timer::new(6);
508        let rows = coordinates
509            .iter()
510            .map(|c| &self.rows[c])
511            .collect::<Vec<_>>();
512        let order = (0..rows.len()).map(|i| (i, 0)).collect::<Vec<_>>();
513        Ok(arrow::compute::interleave_record_batch(&rows, &order)?)
514    }
515    /// Assembles the next global batch and takes its index, regardless of rank.
516    fn assemble(&mut self) -> Result<Option<(Vec<Coordinate>, u64)>> {
517        let mut short_epochs = 0;
518        loop {
519            let stage = &self.config.stages[self.state.stage];
520            let size = stage.batch_size;
521            let drop_last = stage.drop_last;
522            let epoch_boundary = stage.sources.len() == 1 && stage.shuffle.is_none();
523            let mut coordinates = std::mem::take(&mut self.state.partial);
524            while coordinates.len() < size {
525                let Some(c) = self.coordinate()? else {
526                    break;
527                };
528                if epoch_boundary
529                    && coordinates
530                        .first()
531                        .is_some_and(|first| first.epoch != c.epoch)
532                {
533                    self.state.partial.push(c);
534                    break;
535                }
536                coordinates.push(c);
537            }
538            if coordinates.len() < size && drop_last {
539                if self.state.partial.is_empty() {
540                    return Ok(None);
541                }
542                // Two short batches in a row mean a whole epoch fits in one.
543                short_epochs += 1;
544                ensure!(
545                    short_epochs < 2,
546                    "dropLast discards every batch: an epoch has fewer than batchSize rows"
547                );
548                continue;
549            }
550            if coordinates.is_empty() {
551                return Ok(None);
552            }
553            let index = self.state.batch_index;
554            self.state.batch_index += 1;
555            return Ok(Some((coordinates, index)));
556        }
557    }
558    pub fn next(&mut self) -> Result<Option<Batch>> {
559        loop {
560            let Some((coordinates, index)) = self.assemble()? else {
561                return Ok(None);
562            };
563            if index % self.config.world_size != self.config.rank {
564                self.retire();
565                continue;
566            }
567            let columns = self.materialize(&coordinates)?;
568            self.retire();
569            // Finish the round of world_size batches before yielding, so every
570            // rank yields the same count and a finite tail that cannot fill a
571            // round is dropped on all ranks.
572            while !self
573                .state
574                .batch_index
575                .is_multiple_of(self.config.world_size)
576            {
577                if self.assemble()?.is_none() {
578                    return Ok(None);
579                }
580                self.retire();
581            }
582            self.state.batches_yielded += 1;
583            self.state.rows_yielded += coordinates.len() as u64;
584            return Ok(Some(Batch {
585                columns,
586                stage: self.state.stage,
587                index,
588                state: self.state.clone(),
589            }));
590        }
591    }
592    /// Draws the global batches owned by other ranks up to the next multiple of
593    /// world_size. Ranks that advance after the same number of local batches
594    /// then switch stages from one identical cursor and stay partitioned.
595    pub fn align(&mut self) -> Result<()> {
596        while !self
597            .state
598            .batch_index
599            .is_multiple_of(self.config.world_size)
600        {
601            if self.assemble()?.is_none() {
602                break;
603            }
604            self.retire();
605        }
606        Ok(())
607    }
608}
609
610impl Drop for Engine {
611    fn drop(&mut self) {
612        for (_, pending) in self.pending.drain() {
613            let _ = pending.recv();
614        }
615    }
616}