File size: 16,653 Bytes
1bfc3f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
// 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 <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <pybind11/stl.h>

#include <algorithm>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>

#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<uint64_t>(static_cast<uint32_t>(left)) << 32) |
         static_cast<uint64_t>(static_cast<uint32_t>(right));
}

inline int32_t key_left(uint64_t key) { return static_cast<int32_t>(key >> 32); }
inline int32_t key_right(uint64_t key) { return static_cast<int32_t>(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<uint64_t>& 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<uint64_t> keys_;
  std::vector<uint32_t> stamps_;
  std::vector<uint64_t> 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<std::vector<int32_t>> lists;
  std::vector<uint64_t> 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<int64_t>(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<size_t>(row_for(key))].push_back(position);
    ++stored;
  }
};

struct Corpus {
  std::vector<int32_t> symbol;  // live token id per slot, -1 once absorbed
  std::vector<int32_t> prev;
  std::vector<int32_t> 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<size_t>(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<int32_t>(index - 1);
      corpus.next[index] = index + 1 == last ? -1 : static_cast<int32_t>(index + 1);
    }
    corpus.actions += stop - start;
    ++corpus.episodes;
  }
}

}  // namespace

py::tuple train_bpe_native(py::iterable cell_chunks, const std::vector<int32_t>& bins,
                            int64_t vocab_size, int64_t max_token_cells, int threads) {
  const int32_t dimension = static_cast<int32_t>(bins.size());
  if (dimension < 1) throw std::invalid_argument("bins must not be empty");
  std::vector<int32_t> 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<py::tuple>(raw_chunk);
    if (chunk.size() != 2) throw std::invalid_argument("each chunk must be (cells, offsets)");
    auto cells = py::cast<py::array_t<int32_t, py::array::c_style | py::array::forcecast>>(chunk[0]);
    auto bounds =
        py::cast<py::array_t<int64_t, py::array::c_style | py::array::forcecast>>(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<int64_t>(corpus.symbol.size());
  int64_t length = initial_length;
  std::vector<int32_t>& symbol = corpus.symbol;
  std::vector<int32_t>& prev = corpus.prev;
  std::vector<int32_t>& next = corpus.next;

  ac2::CounterMap counts(1 << 16);
  OccurrenceIndex index;
  std::vector<std::pair<int32_t, int32_t>> merges;
  std::vector<int32_t> token_cells(static_cast<size_t>(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<int32_t>(position));
  }
  for (size_t row = 0; row < index.lists.size(); ++row) {
    counts.add(index.keys[row], static_cast<int64_t>(index.lists[row].size()));
  }
  initial_pair_types = static_cast<int64_t>(index.lists.size());
  peak_stored = index.stored;

  std::vector<ac2::HeapEntry> 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<size_t>(vocab_size));
  merges.reserve(static_cast<size_t>(vocab_size - atom_count));

  ac2::CounterMap oversized(1 << 12);
  StampSet touched(static_cast<size_t>(vocab_size));
  std::vector<int32_t> occurrences;

  while (static_cast<int64_t>(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<int64_t>(token_cells[left]) + token_cells[right];
    if (span > max_token_cells) {
      oversized.add(key, 1);
      continue;
    }

    const int32_t child = atom_count + static_cast<int32_t>(merges.size());
    token_cells.push_back(static_cast<int32_t>(span));
    observed_cells = std::max(observed_cells, static_cast<int32_t>(span));

    occurrences.clear();
    occurrences.swap(index.lists[static_cast<size_t>(index.slots.get(key) - 1)]);
    index.stored -= static_cast<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(atom_count);
  report["merges"] = static_cast<int64_t>(merges.size());
  report["vocab_size"] = static_cast<int64_t>(atom_count + merges.size());
  report["cells"] = initial_length;
  report["tokens_per_action_initial"] =
      static_cast<double>(initial_length) / static_cast<double>(corpus.actions);
  report["tokens_per_action"] =
      static_cast<double>(length) / static_cast<double>(corpus.actions);
  report["compression"] = static_cast<double>(initial_length) / static_cast<double>(length);
  report["max_token_cells"] = max_token_cells;
  report["max_token_cells_observed"] = static_cast<int64_t>(observed_cells);
  report["oversized_pairs_skipped"] = oversized_pairs;
  report["initial_pair_types"] = initial_pair_types;
  report["pair_types_peak"] = static_cast<int64_t>(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);
}