// Native BPE trainer -- the FAST-style ablation baseline. // // Set-BPE treats one control step as an unordered set of primitive cells and // merges spatially before merging across time. Plain BPE instead serializes // the quantized ``[T, D]`` cell matrix row-major into one symbol sequence and // runs textbook byte-pair encoding on it, which is what FAST does after its // DCT stage. Both trainers consume the identical quantized corpus, // so a paired run isolates the tokenizer structure and nothing else. // // Correctness is identical to the Python reference trainer in // ``actioncodec2/bpe_training.py``, including the tie-break (largest count, // then lexicographically smallest ordered ``(left, right)`` pair) and the // left-to-right application order, so the two emit the same merge list. // // Where the time goes, and what is done about it: // * occurrences of every observed ordered pair are indexed once, so a merge // touches only the positions that actually carry it; // * the corpus is a doubly linked list over one flat symbol array, so // collapsing a pair is O(1) and never rewrites the tail; // * counters live in the same flat linear-probing table as the Set-BPE // kernels (common.hpp), and the heap is lazy with per-merge deduplication // of the keys a merge could have changed. #include #include #include #include #include #include #include #include #include "common.hpp" namespace py = pybind11; namespace { // BPE pairs are ordered, so the key keeps ``left`` in the high word. The // heap tie-break in common.hpp prefers the smallest key, which is therefore the // lexicographically smallest ``(left, right)`` -- the Python reference rule. inline uint64_t ordered_key(int32_t left, int32_t right) { return (static_cast(static_cast(left)) << 32) | static_cast(static_cast(right)); } inline int32_t key_left(uint64_t key) { return static_cast(key >> 32); } inline int32_t key_right(uint64_t key) { return static_cast(key & 0xffffffffull); } // Per-merge set of pair keys whose counters moved. A merge can only touch // pairs incident to its two parents or its child, so the live population is // bounded by a small multiple of the vocabulary; clearing is a generation bump // rather than a table sweep. class StampSet { public: explicit StampSet(size_t capacity_hint) { size_t target = 1024; while (target < capacity_hint * 4) target <<= 1; keys_.assign(target, 0); stamps_.assign(target, 0); mask_ = target - 1; } void reset() { ++generation_; members_.clear(); if (generation_ != 0) return; std::fill(stamps_.begin(), stamps_.end(), 0); generation_ = 1; } void insert(uint64_t key) { size_t slot = mix(key) & mask_; while (stamps_[slot] == generation_) { if (keys_[slot] == key) return; slot = (slot + 1) & mask_; } if ((members_.size() + 1) * 4 > (mask_ + 1) * 3) { grow(); slot = mix(key) & mask_; while (stamps_[slot] == generation_) { if (keys_[slot] == key) return; slot = (slot + 1) & mask_; } } keys_[slot] = key; stamps_[slot] = generation_; members_.push_back(key); } const std::vector& members() const { return members_; } private: static uint64_t mix(uint64_t value) { value ^= value >> 30; value *= 0xbf58476d1ce4e5b9ull; value ^= value >> 27; value *= 0x94d049bb133111ebull; return value ^ (value >> 31); } void grow() { const size_t buckets = (mask_ + 1) << 1; keys_.assign(buckets, 0); stamps_.assign(buckets, 0); mask_ = buckets - 1; generation_ = 1; for (const uint64_t key : members_) { size_t slot = mix(key) & mask_; while (stamps_[slot] == generation_) slot = (slot + 1) & mask_; keys_[slot] = key; stamps_[slot] = generation_; } } std::vector keys_; std::vector stamps_; std::vector members_; size_t mask_ = 0; uint32_t generation_ = 1; }; // Occurrence index. ``slots`` maps a pair key to its 1-based row in // ``lists``; a value of zero means "no row yet", which is exactly the absent // value CounterMap already reports, so no separate presence bit is needed. struct OccurrenceIndex { ac2::CounterMap slots{1 << 16}; std::vector> lists; std::vector keys; int64_t stored = 0; int64_t row_for(uint64_t key) { const int64_t existing = slots.get(key); if (existing > 0) return existing - 1; const int64_t row = static_cast(lists.size()); lists.emplace_back(); keys.push_back(key); slots.add(key, row + 1); return row; } void append(uint64_t key, int32_t position) { lists[static_cast(row_for(key))].push_back(position); ++stored; } }; struct Corpus { std::vector symbol; // live token id per slot, -1 once absorbed std::vector prev; std::vector next; int64_t actions = 0; int64_t episodes = 0; }; void ingest_chunk(Corpus& corpus, const int32_t* cells, int64_t rows, int32_t dimension, const int32_t* offsets, const int32_t* bins, const int64_t* episode_offsets, int64_t episode_count) { for (int64_t episode = 0; episode < episode_count; ++episode) { const int64_t start = episode_offsets[episode]; const int64_t stop = episode_offsets[episode + 1]; if (stop <= start || start < 0 || stop > rows) { throw std::invalid_argument("BPE episode offsets must be increasing and in range"); } const size_t first = corpus.symbol.size(); const int32_t* initial = cells + start * dimension; for (int64_t row = start; row < stop; ++row) { const int32_t* values = cells + row * dimension; for (int32_t dim = 0; dim < dimension; ++dim) { const int32_t value = values[dim]; if ((value >= 0) != (initial[dim] >= 0)) { throw std::invalid_argument("active dimensions must remain constant within an episode"); } if (value < 0) { if (value != -1) throw std::invalid_argument("cells must contain -1 or a valid index"); continue; } if (value >= bins[dim]) throw std::invalid_argument("cell index exceeds its bin count"); corpus.symbol.push_back(offsets[dim] + value); } } const size_t last = corpus.symbol.size(); if (last == first) throw std::invalid_argument("every control step needs an active coordinate"); if (last > static_cast(INT32_MAX)) { throw std::invalid_argument( "BPE corpus exceeds 2^31 primitive cells; lower the fitting coverage"); } corpus.prev.resize(last); corpus.next.resize(last); for (size_t index = first; index < last; ++index) { corpus.prev[index] = index == first ? -1 : static_cast(index - 1); corpus.next[index] = index + 1 == last ? -1 : static_cast(index + 1); } corpus.actions += stop - start; ++corpus.episodes; } } } // namespace py::tuple train_bpe_native(py::iterable cell_chunks, const std::vector& bins, int64_t vocab_size, int64_t max_token_cells, int threads) { const int32_t dimension = static_cast(bins.size()); if (dimension < 1) throw std::invalid_argument("bins must not be empty"); std::vector offsets(dimension + 1, 0); for (int32_t index = 0; index < dimension; ++index) { if (bins[index] < 1) throw std::invalid_argument("every dimension needs at least one bin"); offsets[index + 1] = offsets[index] + bins[index]; } const int32_t atom_count = offsets[dimension]; if (vocab_size < atom_count) { throw std::invalid_argument("vocab_size is below the number of primitive atoms"); } if (max_token_cells < 1) throw std::invalid_argument("max_token_cells must be positive"); // Greedy merge updates are serial. Preserve the requested option for the // shared trainer API without changing the calling thread's OpenMP settings. const int thread_count = 1; Corpus corpus; for (py::handle raw_chunk : cell_chunks) { py::tuple chunk = py::cast(raw_chunk); if (chunk.size() != 2) throw std::invalid_argument("each chunk must be (cells, offsets)"); auto cells = py::cast>(chunk[0]); auto bounds = py::cast>(chunk[1]); auto cells_view = cells.unchecked<2>(); auto bounds_view = bounds.unchecked<1>(); if (cells_view.shape(1) != dimension) { throw std::invalid_argument("cells must have one column per bin count"); } if (bounds_view.shape(0) < 2) throw std::invalid_argument("offsets must bound one episode"); { py::gil_scoped_release release; ingest_chunk(corpus, cells_view.data(0, 0), cells_view.shape(0), dimension, offsets.data(), bins.data(), bounds_view.data(0), bounds_view.shape(0) - 1); } } if (corpus.symbol.empty()) throw std::invalid_argument("BPE training needs one episode"); const int64_t initial_length = static_cast(corpus.symbol.size()); int64_t length = initial_length; std::vector& symbol = corpus.symbol; std::vector& prev = corpus.prev; std::vector& next = corpus.next; ac2::CounterMap counts(1 << 16); OccurrenceIndex index; std::vector> merges; std::vector token_cells(static_cast(atom_count), 1); int64_t initial_pair_types = 0; int64_t heap_pops = 0; int64_t counter_updates = 0; int64_t compactions = 0; int64_t peak_stored = 0; int64_t oversized_pairs = 0; int32_t observed_cells = 1; { py::gil_scoped_release release; // One pass builds ascending occurrence lists; the counter is their length. for (int64_t position = 0; position < initial_length; ++position) { const int32_t follower = next[position]; if (follower < 0) continue; index.append(ordered_key(symbol[position], symbol[follower]), static_cast(position)); } for (size_t row = 0; row < index.lists.size(); ++row) { counts.add(index.keys[row], static_cast(index.lists[row].size())); } initial_pair_types = static_cast(index.lists.size()); peak_stored = index.stored; std::vector heap; heap.reserve(index.lists.size()); counts.for_each([&](uint64_t key, int64_t value) { heap.push_back({value, key, 0}); }); std::make_heap(heap.begin(), heap.end(), ac2::HeapOrder{}); token_cells.reserve(static_cast(vocab_size)); merges.reserve(static_cast(vocab_size - atom_count)); ac2::CounterMap oversized(1 << 12); StampSet touched(static_cast(vocab_size)); std::vector occurrences; while (static_cast(atom_count + merges.size()) < vocab_size && !heap.empty()) { const ac2::HeapEntry top = ac2::heap_pop(heap); ++heap_pops; const uint64_t key = top.key; const int64_t frequency = counts.get(key); if (frequency <= 0 || frequency != top.count) continue; if (oversized.get(key) != 0) continue; const int32_t left = key_left(key); const int32_t right = key_right(key); const int64_t span = static_cast(token_cells[left]) + token_cells[right]; if (span > max_token_cells) { oversized.add(key, 1); continue; } const int32_t child = atom_count + static_cast(merges.size()); token_cells.push_back(static_cast(span)); observed_cells = std::max(observed_cells, static_cast(span)); occurrences.clear(); occurrences.swap(index.lists[static_cast(index.slots.get(key) - 1)]); index.stored -= static_cast(occurrences.size()); // Left-to-right application is what makes overlapping runs deterministic; // later appends leave the list unsorted, so restore the order first. std::sort(occurrences.begin(), occurrences.end()); touched.reset(); touched.insert(key); int64_t applied = 0; for (const int32_t position : occurrences) { if (symbol[position] != left) continue; const int32_t follower = next[position]; if (follower < 0 || symbol[follower] != right) continue; const int32_t before = prev[position]; const int32_t after = next[follower]; if (before >= 0) { const uint64_t stale = ordered_key(symbol[before], left); counts.add(stale, -1); touched.insert(stale); } if (after >= 0) { const uint64_t stale = ordered_key(right, symbol[after]); counts.add(stale, -1); touched.insert(stale); } symbol[position] = child; symbol[follower] = -1; next[position] = after; if (after >= 0) prev[after] = position; prev[follower] = -1; next[follower] = -1; if (before >= 0) { const uint64_t fresh = ordered_key(symbol[before], child); counts.add(fresh, 1); index.append(fresh, before); touched.insert(fresh); } if (after >= 0) { const uint64_t fresh = ordered_key(child, symbol[after]); counts.add(fresh, 1); index.append(fresh, position); touched.insert(fresh); } ++applied; } counts.add(key, -applied); counter_updates += 4 * applied; length -= applied; merges.emplace_back(left, right); for (const uint64_t member : touched.members()) { const int64_t value = counts.get(member); if (value > 0) ac2::heap_push(heap, {value, member, 0}); } peak_stored = std::max(peak_stored, index.stored); // Superseded positions stay in the lists of pairs that never won. Sweep // them out once they outnumber the live corpus, so memory tracks the // corpus rather than the merge history. if (index.stored > 3 * length) { int64_t retained = 0; for (size_t row = 0; row < index.lists.size(); ++row) { auto& list = index.lists[row]; if (list.empty()) continue; const int32_t expected_left = key_left(index.keys[row]); const int32_t expected_right = key_right(index.keys[row]); size_t write = 0; for (const int32_t position : list) { const int32_t follower = next[position]; if (symbol[position] != expected_left) continue; if (follower < 0 || symbol[follower] != expected_right) continue; list[write++] = position; } list.resize(write); if (write * 2 < list.capacity()) list.shrink_to_fit(); retained += static_cast(write); } index.stored = retained; ++compactions; } if (heap.size() > 4 * counts.size() + 65536) { heap.clear(); counts.for_each([&](uint64_t member, int64_t value) { heap.push_back({value, member, 0}); }); std::make_heap(heap.begin(), heap.end(), ac2::HeapOrder{}); } } oversized_pairs = static_cast(oversized.live_entries()); } py::list merge_list; for (const auto& merge : merges) { merge_list.append(py::make_tuple(merge.first, merge.second)); } py::dict report; report["episodes"] = corpus.episodes; report["actions"] = corpus.actions; report["atoms"] = static_cast(atom_count); report["merges"] = static_cast(merges.size()); report["vocab_size"] = static_cast(atom_count + merges.size()); report["cells"] = initial_length; report["tokens_per_action_initial"] = static_cast(initial_length) / static_cast(corpus.actions); report["tokens_per_action"] = static_cast(length) / static_cast(corpus.actions); report["compression"] = static_cast(initial_length) / static_cast(length); report["max_token_cells"] = max_token_cells; report["max_token_cells_observed"] = static_cast(observed_cells); report["oversized_pairs_skipped"] = oversized_pairs; report["initial_pair_types"] = initial_pair_types; report["pair_types_peak"] = static_cast(index.lists.size()); report["occurrence_slots_peak"] = peak_stored; report["occurrence_compactions"] = compactions; report["heap_pops"] = heap_pops; report["counter_updates"] = counter_updates; report["threads"] = thread_count; report["requested_threads"] = threads; report["trainer"] = "native_sparse_exact"; return py::make_tuple(merge_list, report); }