From a83d17bf52809ef8348df9ae35d7406b17ffb74c Mon Sep 17 00:00:00 2001 From: Frank McSherry Date: Wed, 12 Aug 2026 17:06:52 -0400 Subject: [PATCH 1/6] Drive VecReduceBackend windows from a resumable merge-walk MergedWalk replaces merged_run: a resumable cursor over one logical batch list, whose first `novel` entries are the retire's novel input and whose rest are its prior history, holding a (chunk, offset) per batch, an index into `changed`, and the next active hash. Only the novel prefix and `changed` activate a key; prior batches are seeked to whatever key the active set names, so a window stays proportional to the work asked for rather than to the accumulated trace. Windows are now formed lazily. The active keys are the merge of the novel batch heads with `changed`, so the whole-trace prescan that built keys_cache is gone, along with keys_stale; keys_cache now holds only the current window's keys, which the output pass replays as its own active set. The budget is spent in records rather than keys, since records are what the presentations cost, and it is checked at key boundaries so a key is never split across windows. Prior records are advanced to the compaction frontier as they are drawn and the draw is consolidated per (value, role, time). Advancement without consolidation cannot reduce anything, which is why the frontier bought nothing before; together they remove 46% of the presented records on the benchmark. Identifiers are minted from one namespace shared by both input runs and only after consolidation, so a novel retraction cancels against its own history and a value whose records all cancel spends nothing. Times and diffs move into the bridges rather than being cloned into them, leaving one owned time per presented record. Seeks are searches, the chunk advance included, and a run's end is found by doubling rather than by bisecting the records behind it. Both carry a fast path for an already-positioned cursor: searching the dense path rather than comparing cost 1.71x on churn. Against the previous backend: multimoment 21.2ms -> 17.5ms, wide 31.3ms -> 26.4ms, churn 27.6ms -> 25.7ms, propagate 3.03ms -> 3.15ms. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/operators/int_proxy/vec_backend.rs | 426 +++++++++++------- 1 file changed, 253 insertions(+), 173 deletions(-) diff --git a/differential-dataflow/src/operators/int_proxy/vec_backend.rs b/differential-dataflow/src/operators/int_proxy/vec_backend.rs index 75f1bccfa..119e4c9b1 100644 --- a/differential-dataflow/src/operators/int_proxy/vec_backend.rs +++ b/differential-dataflow/src/operators/int_proxy/vec_backend.rs @@ -12,13 +12,16 @@ //! the `u64` key hash and whose value is the full `(key, val)` pair. //! //! Per window, the backend presents three proxy bridges — novel input, prior input, and prior output. -//! The identifiers chosen are based on ordinal position. Unfortunately, they are chosen independently -//! for the novel and prior inputs, meaning that empty input collections may be supplied to the logic -//! for evaluation, where the logic should be ignored and zero -> zero enforced. This could be fixed -//! with a more attentive identifier selection. +//! Identifiers are ordinal positions minted by a single [`MergedWalk`] over the novel batches and the +//! prior ones together, so a value carries ONE identifier whichever run it appears in and a novel +//! retraction cancels against its own history. They are minted after the walk consolidates, so a value +//! whose records all cancel never reaches the tactic and never spends an identifier. +//! +//! Windows are cut by a record budget rather than a key count, because records are what the three +//! presentations cost and what the harness holds live at once. The budget is checked at key +//! boundaries, so a key is always presented whole. Nothing is enumerated ahead of the walk: the active +//! keys are the merge of the novel batch heads with `changed`, taken lazily. //! -//! The backend crawls all keys at once, rather than respecting the window setting. -//! This is a defect that should be fixed, but the backend shouldn't be used at scale. //! The backend clones keys and values like it is being paid to waste cycles. //! Generally, this is not a high-performance backend, but shouldn't be abysmal. @@ -27,6 +30,7 @@ use std::rc::Rc; use timely::container::PushInto; use timely::progress::Timestamp; +use timely::progress::frontier::AntichainRef; use crate::consolidation::{consolidate, consolidate_updates}; use crate::difference::Semigroup; @@ -45,14 +49,12 @@ type VBatch = Rc>>; pub struct VecReduceBackend { /// User supplied reduce closure. logic: L, - /// Configuration: keys per window, to size the steps the backend performs. + /// Configuration: records per window, bounding what the three presentations cost at once. window_size: usize, - /// All active keys, either novel input or supplied as externally changed. - /// This is *not* windowed, which is a defect to fix. + /// The current window's active keys, in ascending order: the input pass records them, and the + /// output pass replays them as its own active set. keys_cache: Vec, - /// A state bit indicating that the keys cache should be rebuild (each begin). - keys_stale: bool, /// Resolves an input value id (a per-window ordinal) to its `(key, val)` row. in_pool: Vec<(K, V)>, @@ -73,13 +75,12 @@ impl VecReduceBackend { /// A backend deferring value semantics to `logic`, covering the key space in windows. pub fn new(logic: L) -> Self { Self::with_window(logic, 1 << 12) } - /// A backend with an explicit window size, in keys. + /// A backend with an explicit window budget, in presented records. pub fn with_window(logic: L, window_size: usize) -> Self { VecReduceBackend { logic, window_size: window_size.max(1), keys_cache: Vec::new(), - keys_stale: true, in_pool: Vec::new(), out_pool: Vec::new(), out_ids: HashMap::new(), @@ -104,7 +105,6 @@ where type ROut = R; fn begin(&mut self, tiles: &[Description]) { - self.keys_stale = true; self.tiles = tiles.to_vec(); self.tile_chunks = (0..tiles.len()).map(|_| Vec::new()).collect(); } @@ -119,79 +119,81 @@ where ) { let Some(start) = *from else { return }; - // If the first window: form a list of all active keys, novel or changed. - // TODO: this is wasteful; the in-order chunk keys could be merged instead. - if self.keys_stale { - self.keys_stale = false; - self.keys_cache.clear(); - for batch in instance.input_batches.iter() { - for chunk in batch.chunks.iter() { - self.keys_cache.extend(chunk.as_slice().iter().map(|r| r.0.0)); - } - } - self.keys_cache.sort_unstable(); - self.keys_cache.dedup(); - if !changed.is_empty() { - let mut merged = Vec::with_capacity(self.keys_cache.len() + changed.len()); - let (mut a, mut b) = (0usize, 0usize); - while a < self.keys_cache.len() || b < changed.len() { - let key = match (self.keys_cache.get(a), changed.get(b)) { - (Some(x), Some(y)) => *x.min(y), - (Some(x), None) => *x, - (None, Some(y)) => *y, - (None, None) => unreachable!("loop condition ensures one is present"), - }; - if self.keys_cache.get(a) == Some(&key) { a += 1; } - if changed.get(b) == Some(&key) { b += 1; } - merged.push(key); + // The input presentation: ONE walk over the novel batches followed by the prior ones, so a + // value gets ONE identifier whichever run it appears in. With separate namespaces a value + // retracted by the novel batch could not cancel against its own history, and the tactic + // would schedule a crossing whose input consolidates to nothing. + // + // The walk hands back a key at a time, already advanced to the compaction frontier and + // consolidated, so a value whose records all cancel never reaches here and never spends an + // identifier. Identifiers ascend with the walk, so both bridges emerge sorted by + // `((hash, id), time)` with nothing left to sort. + self.in_pool.clear(); + self.keys_cache.clear(); + let mut walk = MergedWalk::new( + instance.input_batches, + instance.source_batches, + changed, + start, + instance.lower, + ); + let mut drawn: Vec> = Vec::new(); + let mut budget = self.window_size; + while let Some(key) = walk.advance(&mut drawn) { + self.keys_cache.push(key); + // The budget is spent in records rather than keys, because records are what the three + // presentations cost and what the harness must hold live at once. Checking it at a key + // boundary keeps every key whole, which the tactic requires: splitting one across + // windows drops the interaction between the halves. + budget = budget.saturating_sub(drawn.len()); + // `drawn` is dead once the key is presented, so its times and diffs MOVE into the + // bridges rather than being cloned into them: one owned time per presented record, + // which is the floor, since a prior record's advanced time has to be materialized and + // the bridge has to own it. A record that cancelled never arrives here and so never + // pays for a time at all. + // + // Consolidation has already dropped the cancelling records, so a change of value is a + // new identifier and no lookahead is needed to find one. The value is borrowed from the + // batch rather than from `drawn`, so it outlives the drain that yields it. + let mut last: Option<&(K, V)> = None; + for ((data, novel, time), diff) in drawn.drain(..) { + if last != Some(data) { + self.in_pool.push(data.clone()); + last = Some(data); } - self.keys_cache = merged; + let id = (self.in_pool.len() - 1) as u64; + let bridge = if novel { &mut window.novel } else { &mut window.history }; + bridge.push(((key, id), time, diff)); } + if budget == 0 { break; } } + *from = walk.due(); - // Determine the range of active keys to process in this window. - let lo = self.keys_cache.partition_point(|k| *k < start); - if lo == self.keys_cache.len() { - *from = None; - return; - } - let hi = (lo + self.window_size).min(self.keys_cache.len()); - *from = if hi == self.keys_cache.len() { None } else { Some(self.keys_cache[hi]) }; - let keys = &self.keys_cache[lo..hi]; - - // The two input runs, presented apart on ordinal ids from a shared pool. Ids ascend with - // the walk, so each bridge emerges sorted by `((hash, id), time)` with nothing to sort or - // consolidate; a value in both runs gets two ids, reconciled by value in the corrections. - self.in_pool.clear(); - let pool = &mut self.in_pool; - let mut last: Option = None; - merged_run(instance.source_batches, keys, |hash, data, time, diff| { - if last != Some(hash) || pool.last() != Some(data) { - pool.push(data.clone()); - last = Some(hash); - } - window.history.push(((hash, (pool.len() - 1) as u64), time.clone(), diff.clone())); - }); - let mut last: Option = None; - merged_run(instance.input_batches, keys, |hash, data, time, diff| { - if last != Some(hash) || pool.last() != Some(data) { - pool.push(data.clone()); - last = Some(hash); - } - window.novel.push(((hash, (pool.len() - 1) as u64), time.clone(), diff.clone())); - }); - - // The output history, interned into the id namespace corrections mint into. + // The output presentation, over the keys the input pass settled on. Output history alone + // never activates a key, so there are no novel batches here and the key list IS the active + // set — which is why the same walk serves, with an empty novel prefix. self.out_ids.clear(); self.out_pool.clear(); - let (out_pool, out_ids) = (&mut self.out_pool, &mut self.out_ids); - merged_run(instance.output_batches, keys, |hash, data, time, diff| { - let id = *out_ids.entry(data.clone()).or_insert_with(|| { - out_pool.push(data.clone()); - (out_pool.len() - 1) as u64 - }); - window.output.push(((hash, id), time.clone(), diff.clone())); - }); + let mut owalk = MergedWalk::new( + &[], + instance.output_batches, + &self.keys_cache, + start, + instance.lower, + ); + let mut odrawn: Vec> = Vec::new(); + while let Some(key) = owalk.advance(&mut odrawn) { + let mut last: Option<&(K, W)> = None; + for ((data, _, time), diff) in odrawn.drain(..) { + if last != Some(data) { + self.out_ids.insert(data.clone(), self.out_pool.len() as u64); + self.out_pool.push(data.clone()); + last = Some(data); + } + let id = (self.out_pool.len() - 1) as u64; + window.output.push(((key, id), time, diff)); + } + } } #[inline(never)] @@ -332,110 +334,188 @@ where } } -/// Merge-walks `batches`, restricted to `keys`, invoking `logic` on each consolidated non-zero update. +/// One record drawn by [`MergedWalk::advance`], keyed for consolidation. +/// +/// The key is `(data, novel, time)`: grouping by data is what mints one identifier per distinct +/// value, and the `novel` flag separates the two roles *within* a value so that consolidation +/// never cancels a novel update against a prior one. The data is borrowed from the batch — it is +/// cloned once per surviving value, when its identifier is minted, and never per record. +type Drawn<'a, D, T, R> = ((&'a D, bool, T), R); + +/// The length of `slice`'s prefix of records whose hash is `key`, which must be non-empty. +/// +/// Found by doubling rather than by bisecting the whole slice: a key's run within one chunk is +/// usually a handful of records, and a binary search over the thousands that follow it would cost +/// several times the run it is measuring. +fn run_len(slice: &[((u64, D), T, R)], key: u64) -> usize { + debug_assert!(slice.first().is_some_and(|r| r.0.0 == key), "the run must start at `key`"); + let mut step = 1; + while step < slice.len() && slice[step].0.0 == key { step <<= 1; } + let lower = step >> 1; + let upper = step.min(slice.len()); + lower + slice[lower..upper].partition_point(|r| r.0.0 <= key) +} + +/// A resumable merge-walk over a list of batches, delivering one key at a time in `(hash, data, +/// time)` order. /// -/// The merge-walk is in order of `(hash, data, time)`. +/// The list is logically one, with batches `[0, novel)` the retire's novel input and the rest its +/// prior history. Only the novel batches and `changed` make a key ACTIVE. The prior batches are +/// seeked to whatever key the active set names and are never walked in search of one, which is what +/// keeps a window proportional to the work asked for rather than to the accumulated trace: on an +/// iterative computation, where a retire asks for a scattered handful of keys, the difference is +/// between presenting a window and re-reading the whole trace. /// -/// TODO: Not actually correct at the moment, in that the consolidation does not yet occur. -#[inline(never)] -fn merged_run( - batches: &[VBatch], - keys: &[u64], - mut logic: impl FnMut(u64, &D, &T, &R), -) where +/// The state is a `(chunk, offset)` cursor per batch, an index into `changed`, and the next active +/// hash. Nothing is materialized ahead of the walk, so a caller can stop at any key boundary and +/// resume from the hash left in [`due`](Self::due) — which is exactly the windowing the backend owes +/// [`ProxyReduceBackend::next_window`]. +struct MergedWalk<'a, D: Ord + Clone + 'static, T: Lattice + Timestamp, R: Semigroup + Ord + Clone + 'static> { + /// The novel batches, then the prior ones; [`batch`](Self::batch) reads the pair as one list. + novel: &'a [VBatch], + prior: &'a [VBatch], + /// Externally flagged keys, ascending: active whatever the batches hold. + changed: &'a [u64], + /// Cursor into `changed`. + ci: usize, + /// Per-batch `(chunk, offset)` cursor, in `batch` order. + pos: Vec<(usize, usize)>, + /// The least active hash at or above the cursors, or `None` once the key space is spent. + due: Option, + /// The compaction frontier prior records are advanced to as they are drawn. Advancing before + /// the sort is what lets the consolidation that follows collapse a long-but-quiet history to one + /// record per value: advancement is only useful because something merges the collisions it + /// creates. An empty frontier advances nothing. + frontier: AntichainRef<'a, T>, +} + +impl<'a, D, T, R> MergedWalk<'a, D, T, R> +where D: Ord + Clone + 'static, T: Lattice + Timestamp, R: Semigroup + Ord + Clone + 'static, { - if keys.is_empty() { - return; + /// A walk over `novel ++ prior`, positioned at the first active key at or above `from`. + fn new( + novel: &'a [VBatch], + prior: &'a [VBatch], + changed: &'a [u64], + from: u64, + frontier: AntichainRef<'a, T>, + ) -> Self { + let count = novel.len() + prior.len(); + let mut walk = MergedWalk { + novel, + prior, + changed, + ci: changed.partition_point(|k| *k < from), + pos: vec![(0, 0); count], + due: None, + frontier, + }; + for b in 0..count { + walk.seek_to(b, from); + } + walk.refresh(); + walk } - let n = batches.len(); - let (mut ci, mut oi) = (vec![0usize; n], vec![0usize; n]); - let mut cur: Vec<&[((u64, D), T, R)]> = vec![&[]; n]; - // Seek each batch to the first record with hash at or above the window's first key. - let k0 = keys[0]; - for b in 0..n { - let chunks = &batches[b].chunks; - ci[b] = chunks.partition_point(|c| c.as_slice().last().is_some_and(|r| r.0.0 < k0)); - cur[b] = chunks.get(ci[b]).map(|c| c.as_slice()).unwrap_or(&[]); - oi[b] = cur[b].partition_point(|r| r.0.0 < k0); + + /// The number of batches, novel and prior together. + fn count(&self) -> usize { self.novel.len() + self.prior.len() } + + /// The `b`-th batch of the logical list. + fn batch(&self, b: usize) -> &'a ChunkBatch> { + if b < self.novel.len() { &self.novel[b] } else { &self.prior[b - self.novel.len()] } } - let mut scratch: Vec<(&D, &T, &R)> = Vec::new(); - let mut ki = 0usize; - loop { - // Least hash among the batch heads. - let mut minh: Option = None; - for b in 0..n { - if let Some(r) = cur[b].get(oi[b]) { - if minh.is_none_or(|m| r.0.0 < m) { - minh = Some(r.0.0); - } - } - } - let Some(h) = minh else { break }; - while ki < keys.len() && keys[ki] < h { - ki += 1; - } - if ki >= keys.len() { - break; + + /// The hash the `b`-th batch's cursor sits on, or `None` if it is drained. + fn head(&self, b: usize) -> Option { + let (chunk, offset) = self.pos[b]; + self.batch(b).chunks.get(chunk).and_then(|c| c.as_slice().get(offset)).map(|r| r.0.0) + } + + /// The least active hash at or above the cursors, or `None` once the key space is spent. + /// + /// A key is active if a novel batch holds a record for it or it appears in `changed`. Prior + /// history alone never activates a key: with no novel update and nothing due, its reduction has + /// the same input and the same output as it did last round, so there is nothing to reconcile. + fn due(&self) -> Option { self.due } + + /// Recompute the next active hash from the novel heads and the `changed` cursor. + fn refresh(&mut self) { + let from_novel = (0..self.novel.len()).filter_map(|b| self.head(b)).min(); + let from_changed = self.changed.get(self.ci).copied(); + self.due = [from_novel, from_changed].into_iter().flatten().min(); + } + + /// Seek the `b`-th batch's cursor to the first record with hash at or above `key`. + /// + /// Both steps are binary searches: over the chunk last-keys, which ascend across a batch and so + /// form a sparse index that never touches a record, and then over the landed chunk's records. + fn seek_to(&mut self, b: usize, key: u64) { + // Already positioned, or drained. Keys are drawn in ascending order, so a cursor sitting at + // or above `key` is already at the FIRST record at or above it: everything it passed was at + // or below the previous key. This is the dense case — most keys, most batches — and paying + // two binary searches per batch per key there costs more than the whole walk. + if self.head(b).is_none_or(|h| h >= key) { return; } + let chunks = &self.batch(b).chunks; + let (mut chunk, mut offset) = self.pos[b]; + let skip = chunks[chunk.min(chunks.len())..] + .partition_point(|c| c.as_slice().last().is_some_and(|r| r.0.0 < key)); + if skip > 0 { + chunk += skip; + offset = 0; } - if keys[ki] != h { - // `h` is not wanted. Seeking every batch to the next key that IS wanted costs a binary - // search per batch and lands at or above it, so at most one unwanted hash is visited - // per wanted key. Walking `h`'s records instead would make the whole pass linear in the - // accumulated history rather than in what is asked for — which on an iterative - // computation, where a retire asks for a handful of scattered keys, is the difference - // between presenting the window and re-reading the trace. When the key set is dense - // this branch is simply never taken, so there is no threshold to tune. - let next = keys[ki]; - for b in 0..n { - loop { - let Some(chunk) = batches[b].chunks.get(ci[b]) else { cur[b] = &[]; break }; - let slice = chunk.as_slice(); - if slice.last().is_some_and(|r| r.0.0 < next) { - ci[b] += 1; - oi[b] = 0; - continue; - } - cur[b] = slice; - oi[b] += slice[oi[b]..].partition_point(|r| r.0.0 < next); - // Chunks are non-empty (`ChunkBatch::new` asserts it) and the guard above - // skipped any whose last key is below `next`, so this chunk holds a record at - // or above `next` and the search cannot land at the end. The branch below is - // unreachable; it is kept so a violated invariant degrades to a slower walk - // rather than to a batch that silently reads as drained. - debug_assert!(oi[b] < slice.len(), "a chunk kept by the skip guard must hold a record at or above the sought key"); - if oi[b] >= slice.len() { - ci[b] += 1; - oi[b] = 0; - continue; - } - break; - } - } - continue; + if let Some(slice) = chunks.get(chunk).map(|c| c.as_slice()) { + offset += slice[offset..].partition_point(|r| r.0.0 < key); } - scratch.clear(); - for b in 0..n { - while let Some(r) = cur[b].get(oi[b]) { - if r.0.0 != h { - break; - } - scratch.push((&r.0.1, &r.1, &r.2)); - oi[b] += 1; - if oi[b] >= cur[b].len() { - ci[b] += 1; - oi[b] = 0; - cur[b] = batches[b].chunks.get(ci[b]).map(|c| c.as_slice()).unwrap_or(&[]); - } + self.pos[b] = (chunk, offset); + } + + /// Draw the `b`-th batch's run of records for `key` into `into`, advancing its cursor past them. + /// + /// A key's run can straddle chunks, so this walks chunks until one ends the run; within each it + /// takes the whole run at once rather than a record at a time. + fn take_run(&mut self, b: usize, key: u64, into: &mut Vec>) { + let novel = b < self.novel.len(); + let chunks = &self.batch(b).chunks; + while let Some(slice) = chunks.get(self.pos[b].0).map(|c| c.as_slice()) { + let start = self.pos[b].1; + // The cursor is at or above `key`; if it is above, this batch holds nothing for it. + // Most batches hold nothing for most keys, so this is the common exit and it must cost + // one comparison rather than a search. + if slice.get(start).is_none_or(|r| r.0.0 != key) { return; } + let end = start + run_len(&slice[start..], key); + into.extend(slice[start..end].iter().map(|record| { + let mut time = record.1.clone(); + // Novel times are the interesting-time seeds the tactic's schedule is stated over, + // so they are drawn unadvanced; only the prior history is lifted. + if !novel { time.advance_by(self.frontier); } + ((&record.0.1, novel, time), record.2.clone()) + })); + if end < slice.len() { + self.pos[b].1 = end; + return; } + self.pos[b] = (self.pos[b].0 + 1, 0); } - { - scratch.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); - for (d, t, r) in scratch.drain(..) { - logic(h, d, t, r); - } + } + + /// Draw every record of the next active key, and advance to the one after it. + /// + /// Returns the key drawn, or `None` once the key space is spent. `into` is left sorted by + /// `(data, novel, time)` and consolidated, so a value whose records all cancel is gone before + /// the caller ever sees it — and so never spends an identifier. + fn advance(&mut self, into: &mut Vec>) -> Option { + let key = self.due?; + into.clear(); + for b in 0..self.count() { + self.seek_to(b, key); + self.take_run(b, key, into); } + consolidate(into); + if self.changed.get(self.ci) == Some(&key) { self.ci += 1; } + self.refresh(); + Some(key) } } From e36b33123bb5e4f57d907da2a5dbb2d1c0be54cb Mon Sep 17 00:00:00 2001 From: Frank McSherry Date: Wed, 12 Aug 2026 17:11:10 -0400 Subject: [PATCH 2/6] Document that a retire evaluates only within its interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy backend advances the accumulated input and output to `lower` as it loads them, which is sound only because no time below `lower` is ever evaluated or used as a comparison target. That was true but stated nowhere, so the backend depended on an invariant a reader could not check. `ReduceTactic::retire` now says what the interval bounds: every time a tactic evaluates at lies in `[lower, upper)`, times at or beyond `upper` are deferred rather than evaluated, and times below `lower` were evaluated by an earlier call. The bound is one-sided in the sense that matters — a sweep's frontier does reach past `upper`, to defer what it finds there — so the clause speaks about evaluation rather than about every time visited. `ReduceInstance::lower` now carries the consequence for a backend: it may and should advance by the frontier, but only together with a consolidation, since advancement alone rewrites times and removes no records. The novel input is exempt, as its times are the interesting-time seeds. Co-Authored-By: Claude Opus 5 (1M context) --- differential-dataflow/src/operators/int_proxy/reduce.rs | 7 +++++++ differential-dataflow/src/operators/reduce.rs | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/differential-dataflow/src/operators/int_proxy/reduce.rs b/differential-dataflow/src/operators/int_proxy/reduce.rs index 736fcf817..1e755fa64 100644 --- a/differential-dataflow/src/operators/int_proxy/reduce.rs +++ b/differential-dataflow/src/operators/int_proxy/reduce.rs @@ -25,6 +25,13 @@ pub struct ReduceInstance<'a, B1: BatchReader, B2: BatchReader