Skip to content

fix(training): scale data prep + dataloader to 3B-token corpora (ZEB-137)#256

Open
jenglund wants to merge 6 commits into
mainfrom
zeblith/zeb-137-harmony-350m-pretraining
Open

fix(training): scale data prep + dataloader to 3B-token corpora (ZEB-137)#256
jenglund wants to merge 6 commits into
mainfrom
zeblith/zeb-137-harmony-350m-pretraining

Conversation

@jenglund
Copy link
Copy Markdown
Contributor

@jenglund jenglund commented Apr 19, 2026

Summary

Four independent memory bugs collectively blocked ZEB-137 Step 0 (FineWeb-Edu-3B prep) and the downstream smoke test. Each surfaced as the one before it was removed; all four are fixed in this PR.

The four bugs

1. prepare_data.pylist[int] token accumulation

Python lists of ints cost ~36 B/token (28 B PyLong + 8 B slot). At 3 B tokens that's 108 GB. Fixed with array.array('H') (2 B/token; Mistral vocab 32000 fits uint16 with a loud-fail assert for larger vocabs).

2. prepare_data.py — glibc + pyarrow pool retention

Even after Python frees per-document tokenizer.encode() allocations, glibc holds freed segments in its arenas (~33 B/token leaked fragmentation) and pyarrow retains streamed shard chunks in its own pool (~1 GB). Without draining, RSS climbs past 7 GB at 100 M tokens; at 3 B it OOMs. Fixed with gc.collect() + pa.default_memory_pool().release_unused() + libc.malloc_trim(0) every 10k docs — keeps RSS flat at ~2.6 GB steady state.

3. prepare_data.pyDataset.from_dict materialization + int32 offset overflow

Dataset.from_dict(arr_2d).save_to_disk() materializes the full dataset in memory during conversion (needs >2× data size), spiking 29 GB peak at 3 B tokens. The default variable-length ListArray path ALSO uses pa.int32() offsets, overflowing when n_rows × seq_len > 2^31 ≈ 2.15 B — the 3 B-token prep produces ~2.99 B items and fails with ArrowInvalid: Value 2147483648 too large to fit in C integer type. Fixed with a pa.ipc.RecordBatchStreamWriter writing 10k-row batches directly. Schema is FixedSizeListArray (no offsets at all; also ~0.05% more compact on disk). Manually emit the dataset_info.json and state.json sidecars that load_from_disk expects. Peak per-batch memory bounded at ~80 MB.

4. train.py::make_hf_dataloader — same list[int] bug, different code path

for example in dataset: all_tokens.extend(example["input_ids"])torch.tensor(all_tokens, dtype=torch.long). Same 36 B/token overhead, OOM at the same threshold. Fixed with dataset.data.column("input_ids").chunks[*].values.to_numpy(zero_copy_only=True)np.concatenate into an int32 buffer → torch.from_numpy. Batches cast to int64 at yield time for nn.Embedding compatibility. Handles both FixedSizeList (new prep output) and legacy variable-length List columns.

Validation

  • Tests: 11 prepare_data tests pass; 2 make_hf_dataloader compat tests pass. The 6 pre-existing CSV-column-count failures (TestCsvLogging, TestOverfit, TestGradientAccumulation) exist on HEAD without this patch — they track against recent ι work adding forensic columns that the tests haven't been updated for.
  • End-to-end 3B prep: rc=0, 47 min wall time, peak RSS 8.99 GB on a 46 GB host (previous run before the patch hit 29 GB and OOM-killed at save). On-disk artifacts:
    • data/fineweb-edu-3b/train: 1,451,598 chunks = 2.97 B tokens, 12 GB
    • data/fineweb-edu-3b/val: 14,662 chunks = 30 M tokens, 115 MB
    • Schema: List(Value('int32'), length=2048) (FixedSizeList)
    • load_from_disk roundtrips cleanly, including row 1,000,000 (above the old int32-offset-overflow threshold)
  • Dataloader on 3B dataset: 6.5 s init, 22.7 GB peak RSS (12 GB persistent tensor + ~10 GB transient arrow page cache), batches shape (32, 2049) int64 with values in Mistral vocab range [2, 30827]. Steady-state RAM during training projects to ~18-20 GB.

Operational note

The worktree's training/.venv is a symlink to main/training/.venv, whose editable ct87 install has a hardcoded MAPPING in __editable___ct87_0_1_0_finder.py pointing at main/training/ct87/. Edits to worktree source files are silently ignored unless the MAPPING is redirected. Not in-code but noted here and in the session memory for future sessions.

Also included

  • training/run_prep_with_rss.sh: wraps a prep run with an RSS sampler writing CSV alongside the stdout log. Useful for any future streaming-tokenization runs.
  • training/ct87/diag_memory.py: reproduces the four memory-leak patterns (A: iterate text only, B: tokenize + discard, C: tokenize + accumulate, D: C with periodic trim) in a single script. Concrete artifact for the investigation; kept for future debugging.

Test plan

  • All prepare_data unit tests pass
  • make_hf_dataloader unit tests pass
  • Full 3B prep runs with rc=0 and expected output (47 min, 8.99 GB peak)
  • Dataloader loads 3B dataset without OOM (6.5 s init, 22.7 GB peak)
  • Smoke test (Step 1 per the spec) — passed, 71 min wall time, peak VRAM 16.8 GB, final train_loss=5.98 val_loss=6.02 (see comment for full results)
  • Full pretraining run (Step 2) — complete, 43.5h wall time, final val_loss=3.23 (within spec 3.0-3.5)

🤖 Generated with Claude Code


Note

Medium Risk
Touches the data-prep and training dataloader paths, changing in-memory representations and Arrow serialization; regressions could surface as subtle data corruption or high RAM usage during training at scale.

Overview
Enables multi-billion-token pretraining runs by reworking ct87.prepare_data and make_hf_dataloader to avoid Python list[int] token materialization and to load tokens from Arrow more efficiently.

prepare_data.py now tokenizes into an array.array('H'), periodically releases retained heap/Arrow memory during streaming, chunks via zero-copy NumPy views, and writes HF-compatible datasets directly with PyArrow streaming IPC using fixed-size lists to avoid offset overflow and large peak memory.

Adds operational tooling (ct87.diag_memory.py, run_prep_with_rss.sh) plus new unit tests for parameter validation and friendlier dataloader failure modes (bad args, DatasetDict paths, empty datasets).

Reviewed by Cursor Bugbot for commit c35eca5. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Documentation

    • Added a comprehensive Harmony pretraining design spec covering phased execution, checkpoints, budgets, monitoring, risks, and handoff notes.
  • Chores

    • Added a standalone memory-diagnostic tool and a prep-run RSS monitoring script.
    • Optimized data-preparation to use streaming Arrow outputs, typed buffers, periodic memory reclamation, and safer validation.
    • Improved training data loader to use on-disk token buffers and fail-fast validations for robustness.
  • Tests

    • Added tests for parameter validation and clearer user-facing errors for empty or mis-specified datasets.

jenglund and others added 3 commits April 17, 2026 17:42
Pretrain HarmonyModelConfig.target() (24L/1280H/~350M params) on
~2B tokens of expanded FineWeb-Edu (Option B, ~35% of Chinchilla-
optimal). Produces 6 intermediate checkpoints spanning 20-100% of
training budget for ZEB-138 capacity-curve sweep, plus the final
canonical checkpoint that serves as substrate for both ZEB-138
scale-up and teacher-match experiments.

Zero code changes - train.py --config target is already wired at
line 847, prepare_data.py --max-tokens is parameterized, and
--save-every / --checkpoint-interval handle artifact drops and
resumability.

Primary hardware: KRILE (RTX 4090), estimated 5-7 days wall time.
Cloud A100 fallback opt-in when wall time justifies: ~2 days wall,
~$55-75 spend, within the $1000 research-program cloud budget cap.

Embraces the commodity-hardware research axis (what can be built on
single-GPU gaming rigs) as part of the Harmony project framing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KRILE flagged during prereq check: HarmonyModelConfig.target()
materializes to ~474M params (L=24 × H=1280 × ffn=3413 SwiGLU ×
vocab=32000 = 473.5M), not the ~350M the spec initially claimed.

Updates throughout:

- Model-size label: 350M → 474M across all body text
- Chinchilla math: 20× params = 9.5B tokens (not 7B); Option B at
  ~21% of Chinchilla (not 35%)
- Step-time estimate: 85-105s at optimized throughput (up from
  60-80s); KRILE to verify in Step 1 smoke test
- Wall time: ~8.1 days (up from 6.3d); acceptance range 7.6-9.5d
  depending on observed step time
- Cloud A100: ~2.7 days at ~$70-90 (up from 2d / $50-60)
- Option C: 9.5B tokens = ~38 days on KRILE / ~$350 cloud (up from
  18-25 days / $250)
- Smoke-test acceptance window updated to 85-105s
- Pre-flight checklist step-time window updated

Checkpoint naming corrected to match actual train.py output:
model_step_N.safetensors + optimizer_step_N.pt pairs plus rolling
checkpoint.pt from --checkpoint-interval (previously placeholder
ckpt_N.pt names that don't correspond to actual artifacts).

Added naming note under header explaining filename/branch still
reference "350M" as a legacy handle; content reflects the true
474M figure.

No code change required — target() as-defined is the intended
substrate; the 350M label was loose at spec-write time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…137)

Both prepare_data.py and make_hf_dataloader accumulated tokens as list[int]
(~36 B/token of PyLong overhead), OOM'ing a 46 GB host around 1.4 B tokens
and blocking ZEB-137 Step 0 onwards. Scaling to the 3 B-token FineWeb-Edu-3B
target required four independent fixes, each surfacing as the one before it
was removed:

1. prepare_data: token_stream is array.array('H') (2 B/token; Mistral
   vocab 32000 fits uint16) instead of list[int].

2. prepare_data: gc + pa.default_memory_pool().release_unused() +
   malloc_trim(0) every 10k docs. Without this, glibc holds freed
   tokenizer-churn segments in its arenas and pyarrow retains streamed
   shard chunks in its pool, together leaking ~40 B/token of unreleased
   fragmentation.

3. prepare_data: write arrow IPC stream directly in 10k-row batches via
   pyarrow, bypassing Dataset.from_dict which materializes the full
   dataset in memory before saving. Uses FixedSizeList schema because
   variable-length ListArray's int32 offsets overflow above ~1.05 M rows
   (our 3 B-token prep produces ~1.46 M rows). Emits the dataset_info.json
   and state.json sidecars load_from_disk expects.

4. make_hf_dataloader: drops to dataset.data.column("input_ids") and
   concatenates arrow chunks into a single int32 numpy buffer (zero-copy
   from mmap), then torch.from_numpy. Batches cast to int64 at yield
   for nn.Embedding compatibility. Handles both FixedSizeList (new prep
   output) and legacy variable-length List columns.

Validated: all 11 prepare_data tests + 2 make_hf_dataloader tests pass
(6 unrelated ι CSV-column-count test failures predate this branch). Full
3B prep: rc=0, 47 min wall time, 8.99 GB peak RSS, 12 GB train + 115 MB
val on disk, load_from_disk roundtrips correctly at all offsets. Dataloader
on the 3B dataset: 6.5 s init, 22.7 GB peak RSS, batches shape (32, 2049)
int64 with values in vocab range.

Also ships two tooling additions used during this investigation:
diag_memory.py reproduces the glibc/pyarrow leak patterns in 4 isolated
scenarios; run_prep_with_rss.sh wraps a prep run with an RSS sampler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 19, 2026

PR author is in the excluded authors list.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 19, 2026

CodeAnt AI is reviewing your PR.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

Adds a Harmony pretraining spec and tooling: Arrow-backed data preparation with typed buffers and periodic memory reclamation, a memory diagnostic CLI, an RSS-monitoring prep wrapper, dataloader changes to use Arrow/NumPy zero-copy buffers, and tests for validation and empty-dataset handling. No public API signatures were changed.

Changes

Cohort / File(s) Summary
Specification & Documentation
docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md
New design/spec for ZEB-137 Harmony pretraining (document clarifies ~474M target despite "350M" name). Describes phases, token/compute budgets, checkpointing, CLI commands, monitoring/acceptance criteria, risks, and handoff for ZEB-138.
Data preparation & tooling
training/ct87/prepare_data.py, training/run_prep_with_rss.sh
prepare_data.py: argument validation, token buffer switched to array.array("H"), EOS/vocab bounds enforced, periodic memory reclamation (gc, pyarrow pool, optional malloc_trim), chunking via NumPy zero-copy views, and writes Arrow streaming IPC .arrow files with sidecar metadata. run_prep_with_rss.sh: wrapper that launches prep, samples /proc/<PID>/status to CSV, traps signals, logs output and exit code.
Training pipeline & tests
training/ct87/train.py, training/tests/test_train.py, training/tests/test_prepare_data.py
make_hf_dataloader now loads Arrow input_ids chunks into contiguous NumPy buffers (preferring zero-copy), preserves backing buffers for iterator lifetime, yields torch.long tensors, and raises clearer errors for DatasetDict or empty-token inputs. Tests added for seq_len/val_fraction validation, DatasetDict detection, zero-row Arrow dataset, and batch_size/seq_len guards.
Memory diagnostics
training/ct87/diag_memory.py
New CLI diagnostic that runs four bounded streaming scenarios (text-only, tokenize-only, accumulate, accumulate+periodic release). Reports PID, RSS, throughput; implements rss_kb, malloc_trim, release_all, and per-scenario routines.
Scripts & test additions
training/run_prep_with_rss.sh, training/tests/*
New RSS-monitoring script and multiple test cases covering input validation and negative/edge cases for updated data/train code paths.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Prep as prepare_data.py
    participant Tokenizer as AutoTokenizer
    participant Mem as MemoryManager
    participant Arrow as PyArrowWriter
    participant Disk

    User->>Prep: start run_prepare_data(max_tokens, seq_len, val_fraction)
    Prep->>Tokenizer: stream text -> tokenize
    Tokenizer-->>Prep: token_ids
    Prep->>Prep: append token_ids to array.array("H")
    loop every 10000 docs
        Prep->>Mem: gc.collect() / pyarrow.pool.release_unused()
        alt malloc_trim available
            Prep->>Mem: malloc_trim(0)
        end
    end
    Prep->>Prep: numpy.frombuffer -> reshape -> split train/val
    Prep->>Arrow: write streaming IPC (.arrow) + dataset_info.json/state.json
    Arrow->>Disk: persist arrow batches & metadata
    Disk-->>User: prepared dataset written
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

size:XXL, Review effort 3/5

Poem

🐰 I nibble arrays and count each token,

I trim the crumbs so memory's not broken,
Arrows carry batches down the trail,
Diagnostics hop while metrics prevail,
Hooray — onward to ZEBs without fail.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly summarizes the main change: fixing memory-related bugs to enable data preparation and loading for 3B-token corpora, with explicit reference to the task ZEB-137.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch zeblith/zeb-137-harmony-350m-pretraining

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Fix memory leaks in 3B-token data prep and dataloader; add Harmony-474M pretraining spec

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Fix four critical memory leaks in data preparation and dataloader that blocked 3B-token corpus
  processing
• Replace Python list[int] token accumulation with array.array('H') to reduce per-token overhead
  from 36B to 2B
• Add periodic heap trimming (gc.collect + malloc_trim + pyarrow pool release) every 10k docs to
  prevent RSS climb
• Replace Dataset.from_dict materialization with direct pyarrow RecordBatchStreamWriter to avoid 2×
  peak memory spike
• Fix int32 offset overflow in ListArray by switching to FixedSizeListArray schema for >1.05M rows
• Optimize dataloader to use zero-copy numpy views of arrow buffers instead of materializing Python
  lists
• Add memory diagnostic tool and RSS monitoring script for validation
• Add comprehensive ZEB-137 pretraining specification for 474M Harmony model on 2B tokens
Diagram
flowchart LR
  A["Python list[int]<br/>36B/token"] -->|Replace| B["array.array'H'<br/>2B/token"]
  C["Dataset.from_dict<br/>2x peak memory"] -->|Replace| D["RecordBatchStreamWriter<br/>80MB batches"]
  E["ListArray int32<br/>offsets overflow"] -->|Replace| F["FixedSizeListArray<br/>no offsets"]
  G["Unbounded heap<br/>fragmentation"] -->|Add| H["gc.collect +<br/>malloc_trim every 10k"]
  I["Python list extend<br/>in dataloader"] -->|Replace| J["Zero-copy numpy<br/>arrow views"]
  B --> K["3B tokens<br/>fits in 8.99GB RSS"]
  D --> K
  F --> K
  H --> K
  J --> K
Loading

Grey Divider

File Changes

1. training/ct87/diag_memory.py Testing +177/-0

Memory leak diagnostic tool for data preparation

• New diagnostic tool to isolate memory leaks across four scenarios: text iteration, tokenization,
 array accumulation, and accumulation with heap trimming
• Measures RSS and token throughput every 10k documents to identify which component causes memory
 growth
• Provides baseline for validating the memory fixes in prepare_data.py

training/ct87/diag_memory.py


2. training/ct87/prepare_data.py 🐞 Bug fix +144/-19

Fix four memory bugs blocking 3B-token corpus prep

• Replace list[int] token stream with array.array('H') (uint16) to reduce per-token memory from
 36B to 2B
• Add periodic heap trimming via gc.collect(), pyarrow.default_memory_pool().release_unused(),
 and libc.malloc_trim(0) every 10k documents
• Replace Dataset.from_dict().save_to_disk() with direct pyarrow.ipc.RecordBatchStreamWriter
 writing 10k-row batches to avoid full materialization
• Switch from variable-length ListArray (int32 offset overflow at >2.15B items) to
 FixedSizeListArray (no offsets, compact)
• Manually emit dataset_info.json and state.json sidecars for HF load_from_disk()
 compatibility
• Add vocab size assertion to catch future vocab expansions beyond uint16 range

training/ct87/prepare_data.py


3. training/ct87/train.py 🐞 Bug fix +26/-10

Optimize dataloader to use zero-copy arrow buffers

• Replace eager list concatenation (all_tokens.extend() + torch.tensor()) with zero-copy numpy
 views of arrow buffers
• Extract flat int32 array from pyarrow column chunks via to_numpy(zero_copy_only=True) and
 concatenate with np.concatenate()
• Create torch tensor from numpy buffer via torch.from_numpy() to avoid materializing intermediate
 Python list
• Cast batch to int64 at yield time for nn.Embedding compatibility
• Handle both FixedSizeList (new prep output) and legacy variable-length List columns uniformly
• Add defensive _keep_alive reference to dataset and buffer to prevent HF internals from closing
 mmap handles mid-run

training/ct87/train.py


View more (2)
4. training/run_prep_with_rss.sh Testing +59/-0

RSS monitoring wrapper for data preparation

• New bash script to run data preparation with parallel RSS monitoring
• Spawns prepare_data.py subprocess and samples /proc/[pid]/status every 3 seconds (configurable)
• Logs RSS, VmHWM, VmPeak, VmSize, and process state to CSV for memory profiling
• Captures stdout/stderr to separate log file with timestamp-based naming
• Provides visibility into memory behavior during full 3B-token preparation runs

training/run_prep_with_rss.sh


5. docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md 📝 Documentation +334/-0

ZEB-137 Harmony-474M pretraining design specification

• Comprehensive specification for ZEB-137: pretraining 474M-parameter Harmony model on ~2B tokens of
 FineWeb-Edu
• Corrects initial "350M" label to accurate "474M" (24L × 1280H × 3413 FFN × 32k vocab = 473.5M
 params)
• Defines Option B training target at ~21% of Chinchilla-optimal (2B tokens vs 9.5B optimal) to
 balance quality and wall time
• Provides step-by-step execution plan: Step 0 (dataset prep), Step 1 (200-step smoke test), Step 2
 (full 7800-step run), Step 3 (checkpoint post-processing)
• Estimates ~8.1 days wall time on KRILE (RTX 4090) or ~2.7 days on cloud A100 (~$70-90)
• Specifies hyperparameters, monitoring targets, risk mitigations, and handoff artifacts for
 downstream ZEB-138
• Emphasizes commodity-hardware research axis (single-GPU trainable on consumer GPUs)

docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 19, 2026

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Empty dataset concat crash🐞 Bug ≡ Correctness
Description
make_hf_dataloader() calls np.concatenate() over col.chunks without handling the empty-chunk case,
so an empty on-disk dataset will throw a numpy ValueError before the intended "too few tokens" guard
triggers. This can happen when prepare_data drops all tokens as an incomplete trailing chunk
(len(stream)<seq_len) but still writes a 0-row dataset to disk.
Code

training/ct87/train.py[R63-72]

+    # Drop to the underlying pyarrow table to bypass HF 4.8's Column wrapper
+    # (which no longer returns numpy directly even with set_format). Works
+    # uniformly for FixedSizeList (new prep output) and variable-length List
+    # (legacy POC); both expose .values on each chunk as a flat int32 array.
+    col = dataset.data.column("input_ids")
+    flat = np.concatenate([
+        chunk.values.to_numpy(zero_copy_only=True).astype(np.int32, copy=False)
+        for chunk in col.chunks
+    ])
+    all_tokens_t = torch.from_numpy(flat)
Evidence
The new dataloader builds flat via np.concatenate([... for chunk in col.chunks]); if the dataset
has 0 rows then col.chunks can be empty, and np.concatenate([]) raises immediately, bypassing
the later total < window ValueError. run_prepare_data() can produce 0 complete chunks when
n_complete becomes 0 after dropping the trailing partial chunk, but it still writes the split to
disk via _save_split() even with n_rows=0. There is also an existing test asserting that the
dataloader should raise a friendly ValueError on too-few-token datasets; this empty-dataset case
will now fail with a cryptic numpy error instead.

training/ct87/train.py[62-81]
training/ct87/prepare_data.py[168-186]
training/ct87/prepare_data.py[220-260]
training/tests/test_train.py[100-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`make_hf_dataloader()` builds the flat token buffer with `np.concatenate([...])`, which raises when `col.chunks` is empty (e.g., 0-row dataset). This bypasses the intended `total < window` guard and yields an unhelpful error.

### Issue Context
`prepare_data.run_prepare_data()` can write a split with 0 rows if `len(stream_np) < seq_len` (all tokens discarded as an incomplete chunk). That output should still cause a clear, actionable ValueError from the dataloader.

### Fix Focus Areas
- training/ct87/train.py[63-81]

### Suggested change
Add a pre-check such as:
- if `len(col.chunks) == 0`: set `total=0` (e.g., `flat = np.empty((0,), dtype=np.int32)`) or directly raise the same ValueError used for `total < window`.
- Optionally also handle the case where `col.chunks` is non-empty but sums to 0 elements.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Zero-copy claim incorrect🐞 Bug ⚙ Maintainability
Description
make_hf_dataloader() claims the torch tensor "views the arrow buffer", but the implementation uses
np.concatenate(), which materializes a new contiguous numpy array and copies all tokens into RAM.
This mismatch can lead to incorrect memory expectations and makes future optimization/debugging
harder.
Code

training/ct87/train.py[R47-72]

+    Builds a flat token stream over the on-disk arrow data and yields random
+    windows of [seq_len + 1] tokens packed into batches.
+
+    The stream is held as an int32 torch tensor viewing the arrow buffer
+    (12 B per 3 B tokens) instead of a Python list (~108 GB for the same —
+    list[int] + torch.tensor(list) materializes a PyLong per token and OOMs
+    on multi-billion-token corpora). Batches are cast to int64 at yield time
+    for compatibility with nn.Embedding.

    Raises ValueError if the dataset has fewer tokens than seq_len + 1
    (the minimum needed for one input/target pair).
    """
+    import numpy as np
    from datasets import load_from_disk

    dataset = load_from_disk(data_path)
-    all_tokens: list[int] = []
-    for example in dataset:
-        all_tokens.extend(example["input_ids"])
-
-    all_tokens_t = torch.tensor(all_tokens, dtype=torch.long)
-    total = len(all_tokens_t)
+    # Drop to the underlying pyarrow table to bypass HF 4.8's Column wrapper
+    # (which no longer returns numpy directly even with set_format). Works
+    # uniformly for FixedSizeList (new prep output) and variable-length List
+    # (legacy POC); both expose .values on each chunk as a flat int32 array.
+    col = dataset.data.column("input_ids")
+    flat = np.concatenate([
+        chunk.values.to_numpy(zero_copy_only=True).astype(np.int32, copy=False)
+        for chunk in col.chunks
+    ])
+    all_tokens_t = torch.from_numpy(flat)
Evidence
The docstring describes a zero-copy view of the Arrow buffer, but the actual code concatenates
per-chunk numpy views into a new array (np.concatenate), and torch.from_numpy(flat) then
references that new buffer, not the Arrow-backed buffers. This means peak RSS includes at least the
full flat copy plus any Arrow mmaps/page cache, not just a view.

training/ct87/train.py[45-73]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The dataloader docstring/comment says the token stream is held as a tensor viewing the Arrow buffer, but the code concatenates chunks into a new numpy array. This is misleading and causes incorrect memory/behavior assumptions.

### Issue Context
Even if concatenation is required for fast random indexing, the documentation should reflect the true behavior: it creates a contiguous int32 buffer in RAM.

### Fix Focus Areas
- training/ct87/train.py[45-73]

### Suggested change
Update the docstring/comment to explicitly state:
- per-chunk conversion is zero-copy where possible,
- but `np.concatenate` builds a new contiguous int32 buffer (full copy),
- and that buffer backs `all_tokens_t`.
Optionally mention peak-memory implications during init.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

3. Hard glibc dependency🐞 Bug ☼ Reliability
Description
run_prepare_data() unconditionally loads "libc.so.6" and calls malloc_trim(0), which will raise
OSError on non-glibc systems (e.g., macOS, musl-based containers). This turns the prep script into a
platform-fragile entrypoint even when trimming is only an optimization.
Code

training/ct87/prepare_data.py[R88-113]

+    # Two memory pools accumulate unbounded during streaming tokenization and
+    # must be drained periodically or RSS grows ~40 B/token and OOMs a 46 GB
+    # host around 1.4 B tokens:
+    #   - glibc malloc holds freed heap segments in arenas after Python frees
+    #     the per-document tokenizer allocations (transient list + PyLongs,
+    #     ~60 KB/doc). malloc_trim(0) forces return to the OS.
+    #   - pyarrow has its own memory pool (used by HF Datasets streaming) that
+    #     grows to ~1 GB; release_unused() drops its idle chunks.
+    # Empirically, calling both every 10k docs keeps RSS flat at ~2.6 GB for
+    # the 100M-token dryrun instead of climbing past 7 GB.
+    _libc = ctypes.CDLL("libc.so.6", use_errno=True)
+    _libc.malloc_trim.argtypes = [ctypes.c_int]
+    _libc.malloc_trim.restype = ctypes.c_int
+
+    try:
+        import pyarrow as _pa
+        _pa_pool = _pa.default_memory_pool()
+    except Exception:
+        _pa_pool = None
+
+    def _release_unused_heap() -> None:
+        gc.collect()
+        if _pa_pool is not None:
+            _pa_pool.release_unused()
+        _libc.malloc_trim(0)
+
Evidence
The code directly loads the glibc SONAME without a platform/availability guard, and
_release_unused_heap() always calls into _libc.malloc_trim(0). A similar unguarded pattern
exists in the new ct87.diag_memory helper, which also depends on /proc and glibc.

training/ct87/prepare_data.py[88-113]
training/ct87/diag_memory.py[24-39]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ctypes.CDLL("libc.so.6")` is Linux/glibc-specific; failing to load it prevents data preparation even though trimming is a best-effort memory optimization.

### Issue Context
The repo seems primarily Linux-targeted, but making trimming optional improves robustness (e.g., musl containers) with minimal downside.

### Fix Focus Areas
- training/ct87/prepare_data.py[88-113]
- training/ct87/diag_memory.py[24-39]

### Suggested change
- Wrap `ctypes.CDLL(...)` in `try/except OSError` and disable trimming if unavailable.
- In `_release_unused_heap()`, call `malloc_trim` only when the symbol is loaded.
- Consider logging once when trimming is unavailable so operators understand why RSS behavior may differ.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@codeant-ai codeant-ai Bot added the size:XL This PR changes 500-999 lines, ignoring generated files label Apr 19, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 19, 2026

User description

Summary

Four independent memory bugs collectively blocked ZEB-137 Step 0 (FineWeb-Edu-3B prep) and the downstream smoke test. Each surfaced as the one before it was removed; all four are fixed in this PR.

The four bugs

1. prepare_data.pylist[int] token accumulation

Python lists of ints cost ~36 B/token (28 B PyLong + 8 B slot). At 3 B tokens that's 108 GB. Fixed with array.array('H') (2 B/token; Mistral vocab 32000 fits uint16 with a loud-fail assert for larger vocabs).

2. prepare_data.py — glibc + pyarrow pool retention

Even after Python frees per-document tokenizer.encode() allocations, glibc holds freed segments in its arenas (~33 B/token leaked fragmentation) and pyarrow retains streamed shard chunks in its own pool (~1 GB). Without draining, RSS climbs past 7 GB at 100 M tokens; at 3 B it OOMs. Fixed with gc.collect() + pa.default_memory_pool().release_unused() + libc.malloc_trim(0) every 10k docs — keeps RSS flat at ~2.6 GB steady state.

3. prepare_data.pyDataset.from_dict materialization + int32 offset overflow

Dataset.from_dict(arr_2d).save_to_disk() materializes the full dataset in memory during conversion (needs >2× data size), spiking 29 GB peak at 3 B tokens. The default variable-length ListArray path ALSO uses pa.int32() offsets, overflowing when n_rows × seq_len > 2^31 ≈ 2.15 B — the 3 B-token prep produces ~2.99 B items and fails with ArrowInvalid: Value 2147483648 too large to fit in C integer type. Fixed with a pa.ipc.RecordBatchStreamWriter writing 10k-row batches directly. Schema is FixedSizeListArray (no offsets at all; also ~0.05% more compact on disk). Manually emit the dataset_info.json and state.json sidecars that load_from_disk expects. Peak per-batch memory bounded at ~80 MB.

4. train.py::make_hf_dataloader — same list[int] bug, different code path

for example in dataset: all_tokens.extend(example["input_ids"])torch.tensor(all_tokens, dtype=torch.long). Same 36 B/token overhead, OOM at the same threshold. Fixed with dataset.data.column("input_ids").chunks[*].values.to_numpy(zero_copy_only=True)np.concatenate into an int32 buffer → torch.from_numpy. Batches cast to int64 at yield time for nn.Embedding compatibility. Handles both FixedSizeList (new prep output) and legacy variable-length List columns.

Validation

  • Tests: 11 prepare_data tests pass; 2 make_hf_dataloader compat tests pass. The 6 pre-existing CSV-column-count failures (TestCsvLogging, TestOverfit, TestGradientAccumulation) exist on HEAD without this patch — they track against recent ι work adding forensic columns that the tests haven't been updated for.
  • End-to-end 3B prep: rc=0, 47 min wall time, peak RSS 8.99 GB on a 46 GB host (previous run before the patch hit 29 GB and OOM-killed at save). On-disk artifacts:
    • data/fineweb-edu-3b/train: 1,451,598 chunks = 2.97 B tokens, 12 GB
    • data/fineweb-edu-3b/val: 14,662 chunks = 30 M tokens, 115 MB
    • Schema: List(Value('int32'), length=2048) (FixedSizeList)
    • load_from_disk roundtrips cleanly, including row 1,000,000 (above the old int32-offset-overflow threshold)
  • Dataloader on 3B dataset: 6.5 s init, 22.7 GB peak RSS (12 GB persistent tensor + ~10 GB transient arrow page cache), batches shape (32, 2049) int64 with values in Mistral vocab range [2, 30827]. Steady-state RAM during training projects to ~18-20 GB.

Operational note

The worktree's training/.venv is a symlink to main/training/.venv, whose editable ct87 install has a hardcoded MAPPING in __editable___ct87_0_1_0_finder.py pointing at main/training/ct87/. Edits to worktree source files are silently ignored unless the MAPPING is redirected. Not in-code but noted here and in the session memory for future sessions.

Also included

  • training/run_prep_with_rss.sh: wraps a prep run with an RSS sampler writing CSV alongside the stdout log. Useful for any future streaming-tokenization runs.
  • training/ct87/diag_memory.py: reproduces the four memory-leak patterns (A: iterate text only, B: tokenize + discard, C: tokenize + accumulate, D: C with periodic trim) in a single script. Concrete artifact for the investigation; kept for future debugging.

Test plan

  • All prepare_data unit tests pass
  • make_hf_dataloader unit tests pass
  • Full 3B prep runs with rc=0 and expected output (47 min, 8.99 GB peak)
  • Dataloader loads 3B dataset without OOM (6.5 s init, 22.7 GB peak)
  • Smoke test (Step 1 per the spec) — launching in parallel with this PR
  • Full pretraining run (Step 2) — will follow after smoke test passes

🤖 Generated with Claude Code


Note

Medium Risk
Medium risk: rewrites the tokenization/serialization path and the HuggingFace dataloader to avoid OOMs, which could introduce subtle dataset-format or token-stream correctness issues that affect training quality.

Overview
Enables multi‑billion‑token runs (e.g., FineWeb‑Edu 3B) by refactoring ct87.prepare_data to use a compact typed token buffer, periodically release allocator/pyarrow retained memory, and write the dataset directly as streamed Arrow FixedSizeList batches (avoiding in‑memory Dataset.from_dict materialization and large-offset overflow).

Updates train.py’s make_hf_dataloader to build the training token stream by zero‑copy flattening the underlying Arrow chunks into an int32 tensor (casting to int64 only per batch), and adds tooling/docs: a Harmony‑474M pretraining spec plus ct87.diag_memory.py and run_prep_with_rss.sh to diagnose/monitor RSS during prep.

Reviewed by Cursor Bugbot for commit 3e459f8. Bugbot is set up for automated code reviews on this repo. Configure here.


CodeAnt-AI Description

Prepare and train on multi-billion-token datasets without running out of memory

What Changed

  • Preprocessing now keeps tokens in a compact typed buffer and writes the dataset in smaller batches, so 3B-token corpora can be prepared without large memory spikes or offset overflow errors.
  • The preprocessing step now periodically clears unused memory while it runs, which keeps long tokenization jobs from steadily climbing until they fail.
  • The training dataloader now reads the saved token stream directly instead of building a huge Python list first, making large pre-tokenized datasets usable during training.
  • Added a memory diagnostic tool and an RSS monitoring script to track where memory grows during preprocessing runs.

Impact

✅ 3B-token prep without OOM
✅ Stable memory during long preprocessing runs
✅ Training from large token datasets on one machine

🔄 Retrigger CodeAnt AI Review

Details

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 19, 2026

PR Code Suggestions ✨

Latest suggestions up to commit 3e459f8
CategorySuggestion                                                                                                                                    Severity
Possible bug
Unconditional glibc loading can crash preprocessing on platforms where libc.so.6 is unavailable
Suggestion Impact:Replaced unconditional glibc loading and direct malloc_trim calls with a try/except around CDLL loading and a no-op fallback via a _malloc_trim() wrapper, then updated heap-release logic to call the wrapper instead of _libc.malloc_trim(0).

code diff:

-    _libc = ctypes.CDLL("libc.so.6", use_errno=True)
-    _libc.malloc_trim.argtypes = [ctypes.c_int]
-    _libc.malloc_trim.restype = ctypes.c_int
+    #
+    # malloc_trim is glibc-specific — on macOS/musl the CDLL load fails and
+    # we fall back to a no-op. gc + pyarrow release still handle most of the
+    # leak on those platforms; only the tokenizer-churn arena fragmentation
+    # stays (the 40 B/token regime). If a non-glibc environment ever becomes
+    # a real target, revisit with jemalloc_tls/mallctl or similar.
+    try:
+        _libc = ctypes.CDLL("libc.so.6", use_errno=True)
+        _libc.malloc_trim.argtypes = [ctypes.c_int]
+        _libc.malloc_trim.restype = ctypes.c_int
+        def _malloc_trim() -> None:
+            _libc.malloc_trim(0)
+    except (OSError, AttributeError):
+        def _malloc_trim() -> None:
+            pass
 
     try:
         import pyarrow as _pa
         _pa_pool = _pa.default_memory_pool()
-    except Exception:
+    except (ImportError, AttributeError):
         _pa_pool = None
 
     def _release_unused_heap() -> None:
         gc.collect()
         if _pa_pool is not None:
             _pa_pool.release_unused()
-        _libc.malloc_trim(0)
+        _malloc_trim()

Loading libc.so.6 unconditionally causes an immediate runtime failure on non-glibc
environments (for example macOS or musl-based Linux), preventing preprocessing from
starting at all. Make malloc_trim optional by guarding the ctypes.CDLL load and
calling it only when available.

training/ct87/prepare_data.py [98-100]

-_libc = ctypes.CDLL("libc.so.6", use_errno=True)
-_libc.malloc_trim.argtypes = [ctypes.c_int]
-_libc.malloc_trim.restype = ctypes.c_int
+try:
+    _libc = ctypes.CDLL("libc.so.6", use_errno=True)
+    _libc.malloc_trim.argtypes = [ctypes.c_int]
+    _libc.malloc_trim.restype = ctypes.c_int
+except OSError:
+    _libc = None
 
Why it matters? 🤔
  • python -m ct87.prepare_data fails on non-glibc systems.
  • ❌ End-to-end pipeline tests fail on macOS or musl.
  • ⚠️ Developers cannot run preprocessing locally on unsupported platforms.
Steps of Reproduction ✅
1. On a system without `libc.so.6` (for example macOS or musl-based Linux), run the CLI entrypoint documented in the module docstring from the training directory: `python -m ct87.prepare_data --output /tmp/out --max-tokens 1000` (this calls `main()` in `training/ct87/prepare_data.py:152-179`, which in turn calls `run_prepare_data()`).

2. When `run_prepare_data()` executes (function defined at `training/ct87/prepare_data.py:64-149`), the new initialization block in the PR hunk at lines `98-100` runs immediately: `_libc = ctypes.CDLL("libc.so.6", use_errno=True)` followed by `_libc.malloc_trim.argtypes = [...]` and `_libc.malloc_trim.restype = ...`.

3. Because `libc.so.6` is not present on this platform, `ctypes.CDLL("libc.so.6", use_errno=True)` raises an `OSError` before any tokenization, dataset streaming, or Arrow writing logic runs, causing `run_prepare_data()` to abort at import-time of `libc`.

4. Observe that the preprocessing pipeline never reaches the HuggingFace dataset loading or tokenization, and any callers of `run_prepare_data()`—including the end-to-end tests `TestEndToEnd.test_smoke_prepare_data` and `TestEndToEnd.test_output_compatible_with_dataloader` in `training/tests/test_prepare_data.py:9-23` and `:47-60`—fail immediately with this `OSError`, preventing dataset preparation on non-glibc environments.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** training/ct87/prepare_data.py
**Line:** 98:100
**Comment:**
	*Possible Bug: Loading `libc.so.6` unconditionally causes an immediate runtime failure on non-glibc environments (for example macOS or musl-based Linux), preventing preprocessing from starting at all. Make `malloc_trim` optional by guarding the `ctypes.CDLL` load and calling it only when available.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Major
Logic error
Empty datasets can crash during concatenation instead of triggering the intended too-few-tokens validation
Suggestion Impact:The code now builds a list of per-chunk numpy arrays, adds an explicit empty-dataset guard (np.empty(0, dtype=np.int32) when no chunks), and includes comments explaining why, avoiding np.concatenate([]) errors and allowing the intended small-dataset check to run.

code diff:

     # uniformly for FixedSizeList (new prep output) and variable-length List
     # (legacy POC); both expose .values on each chunk as a flat int32 array.
     col = dataset.data.column("input_ids")
-    flat = np.concatenate([
+    chunk_arrays = [
         chunk.values.to_numpy(zero_copy_only=True).astype(np.int32, copy=False)
         for chunk in col.chunks
-    ])
+    ]
+    # A 0-row / 0-chunk dataset (e.g. prep saw fewer tokens than seq_len and
+    # dropped the partial chunk) produces an empty list. np.concatenate([])
+    # would raise its own unhelpful error; fall through to the informative
+    # `total < window` guard below instead.
+    flat = np.concatenate(chunk_arrays) if chunk_arrays else np.empty(0, dtype=np.int32)
     all_tokens_t = torch.from_numpy(flat)
     total = all_tokens_t.numel()

np.concatenate raises ValueError: need at least one array to concatenate when the
dataset contains zero chunks, so the function crashes before reaching its documented
small-dataset guard. Build the chunk list first and handle the empty case with an
explicit empty array so the later total < window check can raise the intended error.

training/ct87/train.py [68-71]

-flat = np.concatenate([
+chunks = [
     chunk.values.to_numpy(zero_copy_only=True).astype(np.int32, copy=False)
     for chunk in col.chunks
-])
+]
+flat = np.concatenate(chunks) if chunks else np.empty(0, dtype=np.int32)
 
Why it matters? 🤔
  • ❌ Training CLI with empty dataset crashes in numpy.concatenate.
  • ⚠️ Empty validation dataset causes same crash in make_hf_dataloader.
  • ⚠️ Users see low-level numpy error, not tokens warning.
Steps of Reproduction ✅
1. Prepare or point `--data` to a HuggingFace dataset on disk that has an `input_ids` column but zero rows (so the underlying Arrow column has zero chunks); this dataset path is what `ct87.train` expects per the CLI usage in `training/ct87/train.py:1-7`.

2. Run the training entrypoint, e.g. `python -m ct87.train --config tiny --data <empty_dataset_path> --steps 200`; in the main training logic at `training/ct87/train.py:1605-1608`, `args.synthetic` is false and it constructs `dataloader = make_hf_dataloader(args.data, seq_len, args.batch_size, args.seed)`.

3. Inside `make_hf_dataloader` in `training/ct87/train.py:42-74`, the code executes `dataset = load_from_disk(data_path)` then `col = dataset.data.column("input_ids")` (line 67 in the PR hunk); because the dataset is empty, `col.chunks` is an empty iterable.

4. The concatenation block at `training/ct87/train.py:68-71` (`flat = np.concatenate([chunk.values.to_numpy(... ) for chunk in col.chunks])`) calls `np.concatenate([])` and raises `ValueError: need at least one array to concatenate` before `total = all_tokens_t.numel()` and the `if total < window` guard run, so the process crashes with a NumPy error instead of the intended small-dataset `ValueError`.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** training/ct87/train.py
**Line:** 68:71
**Comment:**
	*Logic Error: `np.concatenate` raises `ValueError: need at least one array to concatenate` when the dataset contains zero chunks, so the function crashes before reaching its documented small-dataset guard. Build the chunk list first and handle the empty case with an explicit empty array so the later `total < window` check can raise the intended error.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Major

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 19, 2026

CodeAnt AI finished reviewing your PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md`:
- Around line 195-208: The fenced code block that renders the checkpoint
file-tree (the block beginning with "training/checkpoints/harmony_474m_v1/")
should include a language specifier for consistent rendering; update the opening
fence from ``` to ```text (or ```plaintext) so the tree is rendered as plain
text across viewers and add no other changes to the block contents.

In `@training/ct87/diag_memory.py`:
- Around line 51-65: Several print statements use f-strings even though they
contain no placeholders; update them to plain string literals to avoid
misleading f-prefixes. Locate the print calls inside scenario_a_text_only (e.g.,
print(f"\n=== A: iterate text only (no tokenizer, no accumulate) ==="), print(f"
baseline rss=..."), print(f"  final rss=...")) and the analogous print usages in
the other scenario functions referenced (the lines at 70, 91, 115), and remove
the leading "f" so they become normal strings (e.g., print("\n=== A: ... ===")).
Do not change content or interpolation where placeholders are actually present
(keep f-strings only when formatting variables).
- Around line 113-137: scenario_d_accumulate_trim currently only calls
gc.collect() and malloc_trim() and misses releasing pyarrow's pool like
_release_unused_heap() does; update scenario_d_accumulate_trim to call the same
pyarrow release helper (either call _pa_pool.release_unused() directly or invoke
the existing release_all() helper used in prepare_data.py) at the same points
where gc.collect() and malloc_trim() are called (e.g., inside the n % 10_000
branch and at the end) so pyarrow memory is reclaimed for an accurate comparison
with prepare_data.py.
- Around line 162-171: Several lines use semicolon-joined statements (e.g.,
"gc.collect(); malloc_trim()" and print calls) which static analysis flagged;
split each into separate statements to improve readability and satisfy linters.
Locate the blocks around scenario_b_tokenize_only, scenario_c_accumulate and the
gc/malloc_trim calls and replace combined statements like "gc.collect();
malloc_trim()" with two lines calling gc.collect() and malloc_trim() separately,
and ensure the print(...) calls are on their own lines after the cleanup calls
so rss_kb(), malloc_trim, and gc.collect are each invoked on separate
statements.

In `@training/ct87/prepare_data.py`:
- Around line 98-106: The except block that sets _pa_pool is too broad; narrow
the caught exceptions to only ImportError and AttributeError so you don't mask
other failures: in the try that does "import pyarrow as _pa" and "_pa_pool =
_pa.default_memory_pool()", change the blanket "except Exception" to "except
(ImportError, AttributeError)" to handle only import or attribute access
problems while allowing other errors to propagate.
- Around line 228-229: The nested context managers using pa.OSFile(arrow_path,
"wb") as sink and pa.ipc.new_stream(sink, arrow_schema) as writer should be
combined into a single with statement to simplify the code; replace the
two-level nesting around arrow_path, arrow_schema and writer with a single "with
pa.OSFile(arrow_path, 'wb') as sink, pa.ipc.new_stream(sink, arrow_schema) as
writer:" and keep the inner block unchanged.

In `@training/ct87/train.py`:
- Around line 92-94: Pre-allocate a reusable CPU tensor and fill slices instead
of calling torch.stack and .to each iteration: create a buffer like batch_buffer
= torch.empty((batch_size, window), dtype=torch.long) once (adjacent to where
rng/all_tokens_t/window/batch_size are defined), then inside the loop generate
starts = torch.randint(..., generator=rng) and for i, s in enumerate(starts):
batch_buffer[i].copy_(all_tokens_t[s : s + window].to(dtype=batch_buffer.dtype))
(or use .to(dtype=batch_buffer.dtype, copy=False) if needed), and yield
batch_buffer; this avoids repeated torch.stack and extra .to allocations while
keeping symbols starts, batch_buffer, all_tokens_t, window, batch_size, and rng
unchanged.

In `@training/run_prep_with_rss.sh`:
- Around line 16-17: The script assigns WT and then changes directory with cd
"$WT" but doesn't handle a failing cd; update the cd invocation (the line using
the WT variable) to bail out on failure by appending an exit on error (e.g., use
the shellcheck-suggested "|| exit 1" behavior) so that if cd "$WT" fails the
script stops instead of continuing in the wrong directory.
- Line 8: The script currently enables only 'set -u' which leaves the script
non-fail-fast; update the startup options by adding '-e' so failures abort early
(replace the existing 'set -u' occurrence with a combined option such as 'set
-eu' or 'set -euo pipefail') and ensure this change is placed at the top of the
script before any commands or background processes are started.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a9287484-65bb-4c48-a3a4-0e83a60eb114

📥 Commits

Reviewing files that changed from the base of the PR and between fd40d34 and 3e459f8.

📒 Files selected for processing (5)
  • docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md
  • training/ct87/diag_memory.py
  • training/ct87/prepare_data.py
  • training/ct87/train.py
  • training/run_prep_with_rss.sh
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{sh,bash}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{sh,bash}: Always use non-interactive flags with shell file operations (cp, mv, rm) to avoid hanging on confirmation prompts. Use: cp -f source dest, mv -f source dest, rm -f file, rm -rf directory, cp -rf source dest
Use non-interactive flags with scp: use -o BatchMode=yes for non-interactive mode
Use non-interactive flags with ssh: use -o BatchMode=yes to fail instead of prompting
Use non-interactive flags with apt-get: use -y flag
Use non-interactive flags with brew: use HOMEBREW_NO_AUTO_UPDATE=1 environment variable

Files:

  • training/run_prep_with_rss.sh
🪛 markdownlint-cli2 (0.22.0)
docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md

[warning] 195-195: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🪛 Ruff (0.15.10)
training/ct87/prepare_data.py

[warning] 105-105: Do not catch blind exception: Exception

(BLE001)


[warning] 228-229: Use a single with statement with multiple contexts instead of nested with statements

(SIM117)

training/ct87/diag_memory.py

[error] 53-53: f-string without any placeholders

Remove extraneous f prefix

(F541)


[warning] 60-60: Use enumerate() for index variable n in for loop

(SIM113)


[error] 70-70: f-string without any placeholders

Remove extraneous f prefix

(F541)


[error] 91-91: f-string without any placeholders

Remove extraneous f prefix

(F541)


[error] 115-115: f-string without any placeholders

Remove extraneous f prefix

(F541)


[warning] 154-154: Missing return type annotation for private function fresh

(ANN202)


[error] 162-162: Multiple statements on one line (semicolon)

(E702)


[error] 166-166: Multiple statements on one line (semicolon)

(E702)


[error] 170-170: Multiple statements on one line (semicolon)

(E702)

🪛 Shellcheck (0.11.0)
training/run_prep_with_rss.sh

[warning] 17-17: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

(SC2164)

🔇 Additional comments (14)
training/ct87/train.py (3)

47-54: LGTM! Clear documentation of the memory-efficient approach.

The docstring accurately explains the memory savings (12 B vs ~108 GB for 3B tokens) and the cast-to-int64 contract for nn.Embedding compatibility.


83-88: LGTM! Defensive lifetime management.

The _keep_alive tuple correctly holds references to both dataset and flat to prevent HF's internals from closing mmap handles or numpy deallocating the buffer while the iterator is active. The noqa: F841 acknowledges the intentional unused variable.


67-73: Consider clarifying the "legacy POC" format support with concrete handling or tests.

The comment claims FixedSizeList (new prep output) and variable-length List (legacy POC) both expose .values as int32 arrays. However, only FixedSizeListArray with int32 is generated in prepare_data.py, and no tests validate compatibility with legacy datasets. If legacy data must be supported, either:

  • Add a test case demonstrating a legacy dataset that works with the current code, or
  • Implement fallback error handling: wrap .to_numpy(zero_copy_only=True) in a try/except and retry with zero_copy_only=False on ArrowInvalid.

Currently, the code has no protection against zero_copy_only=True raising an exception if the Arrow buffer cannot be zero-copied.

training/ct87/prepare_data.py (6)

72-74: LGTM! Clear docstring update.

The docstring accurately reflects the new implementation using typed buffers with EOS separators.


117-125: LGTM! Robust vocab size validation.

The assertions provide clear failure modes if a larger vocab tokenizer is ever used, preventing silent truncation. The error message helpfully suggests the fix (widening to 'I').


136-139: LGTM! Memory-efficient token accumulation.

Using array.array("H") (2 bytes/token) instead of list[int] (~36 bytes/token) is the correct fix for scaling to 3B tokens. The typecode 'H' matches the uint16 range validated above.


168-173: LGTM! Zero-copy numpy view for chunking.

np.frombuffer creates a view without copying, and the reshape operation is also a view. This keeps memory usage minimal during the chunking phase.


245-257: LGTM! HF-compatible metadata files.

The state.json structure matches what HuggingFace's load_from_disk expects. The fingerprint format is reasonable for caching purposes.


202-204: No changes needed—pa.list_(..., list_size=seq_len) is the correct API for FixedSizeListArray.

The PyArrow documentation confirms that pa.list_(value_type, list_size) is the correct and intended way to define a fixed-size list type schema. The code correctly uses this pattern.

training/run_prep_with_rss.sh (2)

27-33: LGTM! Proper background process handling.

The -u flag for unbuffered Python output ensures logs are flushed immediately, and capturing $! for the PID is correct for the monitoring loop.


39-53: LGTM! Robust RSS polling with graceful handling.

The loop correctly handles process termination (kill -0), missing proc files (conditional || true), and empty values (the -n "$rss" check). The polling approach is appropriate for long-running processes.

docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md (1)

1-334: LGTM! Comprehensive and well-structured spec.

The document clearly defines the training protocol, acceptance criteria, risk mitigations, and handoff expectations. The references to code locations align with the implementation changes in this PR.

training/ct87/diag_memory.py (2)

24-29: LGTM! Correct RSS reading from procfs.

Reading /proc/{pid}/status and parsing VmRSS is the standard approach for process memory monitoring on Linux.


89-110: LGTM! Scenario C correctly mirrors prepare_data.py accumulation pattern.

The array.array("H") usage with .extend() and .append(eos) matches the production code in prepare_data.py (context snippet 1), making this a valid baseline for comparison.

Comment on lines +195 to +208
```
training/checkpoints/harmony_474m_v1/
├── model_step_1500.safetensors + optimizer_step_1500.pt # ~20% budget — early undertrained teacher candidate
├── model_step_3000.safetensors + optimizer_step_3000.pt # ~40% budget
├── model_step_4500.safetensors + optimizer_step_4500.pt # ~60% budget
├── model_step_6000.safetensors + optimizer_step_6000.pt # ~80% budget
├── model_step_7500.safetensors + optimizer_step_7500.pt # ~95% budget
├── model_step_7800.safetensors + optimizer_step_7800.pt # final (100%) — canonical checkpoint
├── checkpoint.pt # rolling resumable (from --checkpoint-interval, overwritten)
├── train.csv # training log
└── config.json # config metadata (auto-generated by train.py)

Step 3 post-processing may rename/package these for ZEB-138 consumer convenience (e.g., stripping optimizer state, packaging model+config+metadata as a single safetensors + JSON sidecar).
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add language specifier to fenced code block.

The file tree listing should specify a language (e.g., text or plaintext) for consistent rendering.

📝 Proposed fix
-```
+```text
 training/checkpoints/harmony_474m_v1/
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 195-195: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/specs/2026-04-17-harmony-350m-pretraining-design.md` around
lines 195 - 208, The fenced code block that renders the checkpoint file-tree
(the block beginning with "training/checkpoints/harmony_474m_v1/") should
include a language specifier for consistent rendering; update the opening fence
from ``` to ```text (or ```plaintext) so the tree is rendered as plain text
across viewers and add no other changes to the block contents.

Comment thread training/ct87/diag_memory.py
Comment thread training/ct87/diag_memory.py
Comment thread training/ct87/diag_memory.py Outdated
Comment thread training/ct87/prepare_data.py Outdated
Comment thread training/ct87/prepare_data.py Outdated
Comment thread training/ct87/train.py
Comment on lines 92 to +94
starts = torch.randint(0, total - window + 1, (batch_size,), generator=rng)
batch = torch.stack([all_tokens_t[s : s + window] for s in starts])
yield batch
yield batch.to(torch.long)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider pre-allocating the batch tensor to avoid repeated allocations.

Each iteration calls torch.stack() which allocates a new tensor, then .to(torch.long) allocates another. For high-throughput training, pre-allocating a reusable buffer could reduce allocation overhead.

That said, since the tensors move to GPU immediately after yield, the CPU allocation pressure may be negligible in practice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@training/ct87/train.py` around lines 92 - 94, Pre-allocate a reusable CPU
tensor and fill slices instead of calling torch.stack and .to each iteration:
create a buffer like batch_buffer = torch.empty((batch_size, window),
dtype=torch.long) once (adjacent to where rng/all_tokens_t/window/batch_size are
defined), then inside the loop generate starts = torch.randint(...,
generator=rng) and for i, s in enumerate(starts):
batch_buffer[i].copy_(all_tokens_t[s : s + window].to(dtype=batch_buffer.dtype))
(or use .to(dtype=batch_buffer.dtype, copy=False) if needed), and yield
batch_buffer; this avoids repeated torch.stack and extra .to allocations while
keeping symbols starts, batch_buffer, all_tokens_t, window, batch_size, and rng
unchanged.

Comment thread training/run_prep_with_rss.sh Outdated
Comment thread training/run_prep_with_rss.sh Outdated
Comment thread training/ct87/train.py
@jenglund
Copy link
Copy Markdown
Contributor Author

Smoke test passed — all spec acceptance criteria met ✅

Ran python -m ct87.train --config target ... --steps 200 --save-every 100 --dtype bfloat16 --gradient-checkpoint --output-dir training/checkpoints/harmony_350m_smoke against the new data/fineweb-edu-3b on KRILE's 4090.

Config adjustment from the spec's smoke command:
Spec specified --batch-size 32 --grad-accum-steps 4, sized for 350M. At the corrected 474M (≈35% more params + proportional activations), B=32 OOM'd on the first step and B=16 grad_accum=8 spilled ~2.5 GB into WSL2's "shared GPU memory" (PCIe-backed system RAM). Dropped to B=8 grad_accum=16 + --gradient-checkpoint which fits cleanly in dedicated VRAM with ~7 GB headroom. Effective batch remains 262144 tokens/optimizer-step.

Results:

Criterion Spec target Actual
Complete 200 steps w/o OOM or NaN Training complete. Final checkpoint at step 200
Train loss drops toward ~7-8 by step 200 7-8 5.98
Val loss materially below log(32000)=10.37 6.02 final
Step time 60-100s ~21s
VRAM peak <18 GB 16.8 GB (no PCIe spillover)
Checkpoints at step 100 + 200 2 ✓ 4 files (2 model + 2 optimizer)

Training-loss curve: 10.87 → 10.65 → 9.93 → 8.74 → 7.72 → 7.52 (end warmup) → 7.41 → 7.31 → 7.25 → 7.07 → 6.95 → 6.80 → 6.66 → 6.54 → 6.44 → 6.37 → 6.25 → 6.17 → 6.05 → 5.98

Wall time: 71 minutes (22:12 → 23:23). Extrapolating to Step 2's 7800 steps at ~21s/step = ~46 h ≈ 2 days rather than the spec's 6-day projection. The 4090 is running ~3× faster than the conservative estimate (plausible: recent PyTorch Flash Attention + BF16 on Ada tensor cores).

Operational note on VRAM:
WSL2's nvidia-smi reports a "shared GPU memory" overflow region that's really pinned host RAM exposed to CUDA over PCIe. Allocations spilling there run 10-50× slower than dedicated VRAM (PCIe ~30 GB/s vs HBM2e 1 TB/s). The initial B=32 and B=16 configs both triggered this silently — visible only by observing nvidia-smi's memory.free climb below ~500 MiB. --gradient-checkpoint + smaller micro-batch fits in dedicated 24 GB with comfortable margin and avoids the PCIe stall.

Test plan checkbox update:

  • Smoke test passes (Step 1 per the spec)

Step 2 (full ~7800-step run) ready to launch once this PR is reviewed / merged.

Bugs (Qodo / CodeAnt / CodeRabbit):

1. Empty-chunks crash in make_hf_dataloader: np.concatenate([]) raises
   an unhelpful ValueError when the dataset has zero rows (can happen when
   prep sees fewer tokens than seq_len and drops the partial chunk). Now
   builds the chunk list first and falls back to np.empty(0, int32) when
   empty so the informative `total < window` guard fires downstream.
   New test (test_zero_rows_raises_friendly_error) locks in the behaviour
   by writing a real 0-row IPC stream + sidecars and asserting the
   actionable ValueError fires.

2. Docstring inaccuracy: the tensor isn't a zero-copy view of the arrow
   buffer — np.concatenate materialises a new contiguous int32 buffer.
   Per-chunk conversion is still zero-copy, but the final stream is a
   copy; docstring now says so explicitly and calls out the 4 B/token
   peak during init.

3. Hard glibc dependency: ctypes.CDLL("libc.so.6") raises OSError on
   macOS/musl. Wrapped in try/except with a no-op fallback so prep can
   still run there (at reduced heap-release effectiveness — the 40 B/token
   regime stays on non-glibc). Same fix in diag_memory.py.

Nits picked up:

- Narrow `except Exception` → `except (ImportError, AttributeError)`
  around pyarrow pool access (prepare_data.py, diag_memory.py).
- Combine nested `with pa.OSFile(...)` / `with pa.ipc.new_stream(...)`
  into a single parenthesised with block.
- Remove extraneous f-prefixes from f-strings without placeholders
  (diag_memory.py scenario headers).
- Split semicolon-joined `gc.collect(); malloc_trim()` calls into
  separate statements (diag_memory.py main).
- Scenario D in diag_memory.py now calls the same release_all() helper
  (gc + pyarrow pool + glibc trim) that prepare_data.py uses — previously
  it was missing the pyarrow pool release and understated the
  reclamation effectiveness.
- run_prep_with_rss.sh: `set -u` → `set -eu`, add `|| exit 1` on cd.

Not taken:

- Pre-allocate batch tensor in make_hf_dataloader (reviewer noted the
  CPU allocation pressure is negligible since tensors move to GPU
  immediately; agreed).
- Spec doc's fenced code block language specifier (not in this PR's
  diff — it's from the upstream spec-correction commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jenglund
Copy link
Copy Markdown
Contributor Author

Review feedback addressed — commit a86f65b2

Thanks for the thorough review. Walking through each item:

Bugs — all fixed

1. Empty-chunks crash (Qodo #1, CodeAnt Major)
np.concatenate([]) raised a cryptic need at least one array when the dataset had zero rows (can happen when prep drops all tokens as an incomplete trailing chunk and writes a 0-row dataset). Now builds the chunk list first and falls back to np.empty(0, int32) when empty so the informative total < window guard fires downstream with the actionable message.

New test test_zero_rows_raises_friendly_error writes a real 0-row IPC stream + sidecars and asserts the friendly ValueError fires. 13/13 prepare_data + make_hf_dataloader tests pass.

2. Zero-copy claim incorrect (Qodo #2)
Docstring updated to accurately describe the path: per-chunk conversion is zero-copy, but np.concatenate materialises a single contiguous int32 buffer. Explicitly calls out the ~4 B/token peak during init, so future readers don't budget as if the tensor were a pure arrow view.

3. Hard glibc dependency (Qodo #3, CodeAnt Major)
ctypes.CDLL("libc.so.6") wrapped in try/except (OSError, AttributeError) with a no-op fallback. On non-glibc platforms (macOS, musl) the _malloc_trim becomes a no-op; gc.collect() + pa.default_memory_pool().release_unused() still run and handle most of the leak. Same fix in diag_memory.py. Comment in-code flags that the ~40 B/token tokenizer-churn fragmentation regime stays on non-glibc — revisit with jemalloc/mallctl if that ever becomes a real target.

CodeRabbit nits — picked up

  • except Exceptionexcept (ImportError, AttributeError) around pyarrow pool access (prepare_data.py, diag_memory.py)
  • Nested with pa.OSFile(...) / with pa.ipc.new_stream(...) combined into a single parenthesised with block
  • Extraneous f prefixes removed from f-strings without placeholders (diag_memory scenario headers)
  • Semicolon-joined gc.collect(); malloc_trim() split into separate statements (diag_memory main)
  • Scenario D consistency bug fix: scenario D in the diagnostic script was missing the pyarrow pool release that prepare_data.py's _release_unused_heap() does — it only called gc.collect() and malloc_trim(). That understated the full-drain effectiveness and made the comparison against prepare_data inaccurate. Now both paths go through a shared release_all() helper so they're identical. (This was a real inconsistency, not just style.)
  • set -uset -eu and cd "$WT" || exit 1 in run_prep_with_rss.sh

Not taken (with reasoning)

  • Pre-allocate batch tensor in make_hf_dataloader (CodeRabbit): The reviewer themselves notes the CPU allocation pressure is negligible since tensors move to GPU immediately. Benchmarks during the smoke test confirmed: step time was ~21s (vs spec's 60-100s estimate), dominated by GPU compute, not host allocations. Skipping the optimization keeps the code more readable.
  • Fenced code block language specifier on the spec doc (CodeRabbit): That's from upstream commit 80a7e67 (Koya's 350M→474M correction), not in this PR's diff. Out of scope.

Smoke test already passed on the prior commit

71 min wall time, 16.8 GB VRAM peak, final train_loss 5.98 / val_loss 6.02 — full details in the earlier comment. The review-feedback fixes are surgical and don't touch the training-path hot loop, so the smoke result still applies. Happy to re-smoke if a reviewer prefers.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@training/ct87/diag_memory.py`:
- Around line 23-28: rss_kb() currently assumes /proc is present and will crash
on non-Linux systems; update it to first detect platform or availability of
/proc and fall back to a portable method (or raise a clear error). Specifically,
in function rss_kb check for existence of f"/proc/{os.getpid()}/status" (or
sys.platform.startswith("linux")) and only parse VmRSS when present; otherwise
use a portable fallback like resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
(convert units as needed) or, if no reliable method is available, raise an
explicit NotImplementedError with a clear message; keep the function name rss_kb
and its int return contract.

In `@training/ct87/prepare_data.py`:
- Around line 180-197: Add early validation at the start of run_prepare_data():
ensure seq_len is an integer > 0 and val_fraction is a number in [0,1) (allow 0
but reject negative or >=1) and raise a clear ValueError or exit with a
descriptive message if these checks fail; this prevents proceeding to
tokenization/stream creation with invalid parameters (refer to run_prepare_data,
seq_len, val_fraction in the diff).

In `@training/ct87/train.py`:
- Around line 65-70: The code assumes load_from_disk(data_path) returns a single
Dataset with a .data attribute but run_prepare_data() may write train/ and val/
subdirs so callers can pass the parent dir and load_from_disk will return a
DatasetDict; update the logic after load_from_disk(data_path) to detect a
DatasetDict (instance of datasets.DatasetDict or has keys like "train"/"val")
and either select the appropriate split (e.g., dataset["train"] or
dataset.get("train")) before accessing .data.column("input_ids"), or raise a
clear ValueError telling callers to pass the specific split directory (e.g.,
".../train" or ".../val"); reference the variables/functions dataset,
load_from_disk, and the access to .data.column("input_ids") when making the
change.

In `@training/run_prep_with_rss.sh`:
- Around line 55-59: Because set -e can cause the script to exit immediately
when wait "$PID" fails, replace the bare wait with an if/else that runs wait
"$PID" and captures its exit status into RC (e.g., if wait "$PID"; then RC=0;
else RC=$?; fi) so that the subsequent lines that append "EXIT=$RC TS=..." to
"$RSS_CSV" and exit with RC always execute; locate the block using wait, PID, RC
and RSS_CSV and implement the if/else around wait, then use the captured RC for
the logging and final exit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e07aa42-8527-4745-9eba-d0a96a1635c1

📥 Commits

Reviewing files that changed from the base of the PR and between 3e459f8 and a86f65b.

📒 Files selected for processing (5)
  • training/ct87/diag_memory.py
  • training/ct87/prepare_data.py
  • training/ct87/train.py
  • training/run_prep_with_rss.sh
  • training/tests/test_train.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{sh,bash}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{sh,bash}: Always use non-interactive flags with shell file operations (cp, mv, rm) to avoid hanging on confirmation prompts. Use: cp -f source dest, mv -f source dest, rm -f file, rm -rf directory, cp -rf source dest
Use non-interactive flags with scp: use -o BatchMode=yes for non-interactive mode
Use non-interactive flags with ssh: use -o BatchMode=yes to fail instead of prompting
Use non-interactive flags with apt-get: use -y flag
Use non-interactive flags with brew: use HOMEBREW_NO_AUTO_UPDATE=1 environment variable

Files:

  • training/run_prep_with_rss.sh
🪛 Ruff (0.15.10)
training/ct87/diag_memory.py

[warning] 81-81: Use enumerate() for index variable n in for loop

(SIM113)


[warning] 176-176: Missing return type annotation for private function fresh

(ANN202)

🔇 Additional comments (2)
training/tests/test_train.py (1)

113-171: Good regression coverage for empty Arrow datasets.

This exercises the same streaming-IPC + sidecar layout that prepare_data now emits and locks in the friendly ValueError instead of NumPy's concatenate failure.

training/ct87/train.py (1)

75-89: Nice empty-dataset fallback.

Falling back to an empty int32 buffer preserves the user-facing total < window error instead of leaking np.concatenate([]) out of the loader.

Comment thread training/ct87/diag_memory.py
Comment thread training/ct87/prepare_data.py
Comment thread training/ct87/train.py
Comment thread training/run_prep_with_rss.sh Outdated
Comment thread training/ct87/prepare_data.py Outdated
Issues addressed (CodeRabbit + Cursor):

1. Redundant pyarrow import (Cursor): pa is already imported unconditionally
   above the try/except, so the fallback branch was unreachable. Dropped
   the try/except — _pa_pool comes from the unconditional `pa` import.

2. Parameter validation in run_prepare_data (CodeRabbit): added fail-fast
   checks for seq_len > 0 and val_fraction in [0.0, 1.0). A bad arg would
   otherwise surface hours into a prep run after CPU+network tokenization
   had already completed.

3. DatasetDict detection in make_hf_dataloader (CodeRabbit): if a caller
   passes a parent directory containing train/val subdirs, load_from_disk
   returns a DatasetDict and the subsequent .data access would AttributeError.
   Now detects and raises ValueError with a clear fix hint ("pass
   .../train or .../val").

4. /proc guard in diag_memory.rss_kb (CodeRabbit): used to open
   /proc/<pid>/status unconditionally; now raises an explicit RuntimeError
   on platforms without /proc. Chose fail-fast over falling back to
   resource.getrusage.ru_maxrss — the latter is peak RSS, not current,
   which would be a confusing semantic shift for a growth-monitoring
   diagnostic.

5. wait + set -e in run_prep_with_rss.sh (CodeRabbit): `set -e` caused a
   bare `wait "$PID"` to exit the script before the CSV's EXIT= row and
   the stdout DONE line were written on a failed prep run — defeating
   the whole post-mortem purpose. Now wraps wait in if/else and captures
   RC so the exit record always writes.

Tests added:
- test_rejects_zero_seq_len, test_rejects_negative_seq_len,
  test_rejects_negative_val_fraction, test_rejects_full_val_fraction
  (TestParameterValidation class in test_prepare_data.py)
- test_datasetdict_raises_friendly_error (TestHfDataloaderGuard in
  test_train.py; builds a real DatasetDict, saves, verifies the
  dataloader surfaces the actionable error)

All 18 prepare_data + dataloader-guard tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jenglund
Copy link
Copy Markdown
Contributor Author

Round 2 of review feedback addressed — commit 5937ce5e

Thanks for the continued careful review. All 5 items (4 CodeRabbit + 1 Cursor) addressed.

1. Redundant pyarrow import (Cursor, low severity) ✅

Valid catch — pa is imported unconditionally at line 84, so the try/except (ImportError, AttributeError) wrapper around a second _pa import was dead code that would never actually handle anything. Dropped the try/except; _pa_pool now comes from the unconditional pa import.

2. Parameter validation in run_prepare_data (CodeRabbit, major) ✅

Added fail-fast checks at the top of run_prepare_data:

  • seq_len must be a positive integer
  • val_fraction must be in [0.0, 1.0) (allow 0, reject negative or ≥ 1)

Previously --seq-len 0 would silently produce n_complete = 0 and emit a 0-row dataset, while val_fraction >= 1.0 would produce a negative-sized train split. Both failure modes are now caught before the ~50-minute tokenization pass starts. Four new unit tests under TestParameterValidation.

3. DatasetDict detection in make_hf_dataloader (CodeRabbit, major) ✅

If a caller passes the parent output dir (e.g. data/fineweb-edu-3b rather than data/fineweb-edu-3b/train), and that parent happens to be a proper DatasetDict save, load_from_disk returns a DatasetDict and the subsequent .data.column("input_ids") crashes with an opaque AttributeError. Now detects DatasetDict up front and raises ValueError with a clear hint:

load_from_disk('...') returned a DatasetDict with splits [train, val]. Pass a split directory such as .../train or .../val instead.

New unit test test_datasetdict_raises_friendly_error constructs a real DatasetDict, saves it, and verifies the actionable error fires.

4. /proc guard in diag_memory.rss_kb (CodeRabbit, major) ✅

Now raises an explicit RuntimeError with a clear message if /proc/<pid>/status isn't present, rather than dying inside open().

Chose fail-fast over fallback to resource.getrusage(ru_maxrss) because ru_maxrss is peak RSS since process start, not current RSS — the diagnostic's whole point is observing current-memory growth over time, and silently swapping in a peak-since-start measurement would produce misleading curves (flat-after-first-peak) that look like success when they're really just the metric being wrong. The leak patterns being reproduced are also glibc-specific, so a non-glibc run wouldn't produce the same bug anyway. Docstring explains the tradeoff.

5. wait + set -e in runner script (CodeRabbit, major) ✅

Good catch. With set -e (which the previous round added), a failing wait "$PID" aborted the script before the final EXIT=<rc> CSV row and DONE rc= line were written — defeating the whole post-mortem purpose. Now wraps wait in if/else, captures RC, and lets the exit record always write:

if wait "$PID"; then
    RC=0
else
    RC=$?
fi
echo "EXIT=$RC TS=$(date +%s)" >> "$RSS_CSV"
echo "DONE rc=$RC"
exit "$RC"

Test summary

18/18 tests pass (prepare_data unit + end-to-end + new parameter-validation + dataloader-guard + DatasetDict + zero-rows).

Smoke test from the earlier commit still applies — none of these fixes touch the training hot path or on-disk format. Happy to re-smoke if preferred.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
training/ct87/train.py (1)

65-99: ⚠️ Potential issue | 🟡 Minor

Validate seq_len and batch_size before loading data.

seq_len=0 currently slips through as window=1, and batch_size=0 does not fail until torch.stack([]). Both should be rejected here with a clear ValueError instead of surfacing as downstream training/runtime errors.

♻️ Proposed fix
 def make_hf_dataloader(
     data_path: str, seq_len: int, batch_size: int, seed: int = 42,
 ) -> Iterator[torch.Tensor]:
@@
     import numpy as np
     from datasets import DatasetDict, load_from_disk
+
+    if seq_len <= 0:
+        raise ValueError(f"seq_len must be > 0, got {seq_len!r}")
+    if batch_size <= 0:
+        raise ValueError(f"batch_size must be > 0, got {batch_size!r}")
 
     dataset = load_from_disk(data_path)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@training/ct87/train.py` around lines 65 - 99, Before calling load_from_disk
in train.py, validate that seq_len and batch_size are positive integers: check
that seq_len > 0 and batch_size > 0 and raise a clear ValueError if not (e.g.
"seq_len must be > 0" and "batch_size must be > 0"); this should be done prior
to computing window = seq_len + 1 (and before any data loading such as
load_from_disk or operations that use seq_len/window) so invalid values are
rejected early with an actionable error instead of failing later in torch.stack
or downstream logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@training/ct87/diag_memory.py`:
- Around line 92-98: Scenario A currently counts empty/whitespace examples (in
the loop that updates total_chars, n, and calls report("A", ...)), causing
n_docs to represent a different workload than B/C/D; update the loop that
iterates over ds to skip examples where example["text"] is empty or only
whitespace (e.g., check example["text"].strip() == "" and continue) before
incrementing total_chars or n and before the n >= n_docs break, so counting and
reporting in Scenario A match the tokenizing scenarios.

In `@training/run_prep_with_rss.sh`:
- Around line 27-65: The script currently launches ct87.prepare_data in
background and never forwards INT/TERM, leaving an orphaned child; add a trap at
top-level that on SIGINT and SIGTERM sends a termination signal to the
background PID (use the PID variable set after launching), then waits for that
PID so the existing footer (the wait/RC capture, RSS_CSV final line and exit)
always runs; ensure the trap handler checks PID is set/non-empty before kill,
forwards the signal (TERM) and optionally falls back to KILL if the child
doesn't exit within a short timeout, then returns so the script continues to the
existing wait/exit path.
- Line 25: The header written to "$RSS_CSV" declares columns
"ts,rss_kb,vmhwm_kb,vmpeak_kb,vmsize_kb,state" but later the script appends a
footer like "EXIT=... TS=..." which is not a valid CSV row for that schema;
update the header to include explicit footer/event columns (e.g., add
"exit_code,event_ts") or change the footer emission to write a normal CSV row
matching the existing header (populate ts and state columns and put the exit
code into a new column if you add one). Locate the header write (echo
"ts,rss_kb,vmhwm_kb,vmpeak_kb,vmsize_kb,state" > "$RSS_CSV") and the
footer/emitted line (the EXIT=... TS=... write around line 63) and make them
consistent so all appended lines are valid CSV rows.

---

Outside diff comments:
In `@training/ct87/train.py`:
- Around line 65-99: Before calling load_from_disk in train.py, validate that
seq_len and batch_size are positive integers: check that seq_len > 0 and
batch_size > 0 and raise a clear ValueError if not (e.g. "seq_len must be > 0"
and "batch_size must be > 0"); this should be done prior to computing window =
seq_len + 1 (and before any data loading such as load_from_disk or operations
that use seq_len/window) so invalid values are rejected early with an actionable
error instead of failing later in torch.stack or downstream logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 41acd4e8-3824-4128-869e-d82e700d5837

📥 Commits

Reviewing files that changed from the base of the PR and between a86f65b and 5937ce5.

📒 Files selected for processing (6)
  • training/ct87/diag_memory.py
  • training/ct87/prepare_data.py
  • training/ct87/train.py
  • training/run_prep_with_rss.sh
  • training/tests/test_prepare_data.py
  • training/tests/test_train.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{sh,bash}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{sh,bash}: Always use non-interactive flags with shell file operations (cp, mv, rm) to avoid hanging on confirmation prompts. Use: cp -f source dest, mv -f source dest, rm -f file, rm -rf directory, cp -rf source dest
Use non-interactive flags with scp: use -o BatchMode=yes for non-interactive mode
Use non-interactive flags with ssh: use -o BatchMode=yes to fail instead of prompting
Use non-interactive flags with apt-get: use -y flag
Use non-interactive flags with brew: use HOMEBREW_NO_AUTO_UPDATE=1 environment variable

Files:

  • training/run_prep_with_rss.sh
🪛 Ruff (0.15.10)
training/tests/test_prepare_data.py

[warning] 98-99: Use a single with statement with multiple contexts instead of nested with statements

(SIM117)


[warning] 107-108: Use a single with statement with multiple contexts instead of nested with statements

(SIM117)


[warning] 116-117: Use a single with statement with multiple contexts instead of nested with statements

(SIM117)


[warning] 125-126: Use a single with statement with multiple contexts instead of nested with statements

(SIM117)

training/ct87/train.py

[warning] 71-75: Prefer TypeError exception for invalid type

(TRY004)


[warning] 71-75: Avoid specifying long messages outside the exception class

(TRY003)

training/ct87/diag_memory.py

[warning] 32-36: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 94-94: Use enumerate() for index variable n in for loop

(SIM113)


[warning] 189-189: Missing return type annotation for private function fresh

(ANN202)

training/ct87/prepare_data.py

[warning] 81-81: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 83-85: Avoid specifying long messages outside the exception class

(TRY003)

Comment thread training/ct87/diag_memory.py
Comment thread training/run_prep_with_rss.sh
Comment thread training/run_prep_with_rss.sh
1. Scenario A workload consistency (CodeRabbit, minor): scenario A was
   counting empty/whitespace rows while B/C/D skipped them, so n_docs meant
   different workloads across scenarios and the RSS comparison drifted
   depending on how many blanks happened to land in the first n_docs of
   the stream. Now all four scenarios apply the same `if not text or not
   text.strip(): continue` filter.

2. Signal forwarding in run_prep_with_rss.sh (CodeRabbit, major): if the
   wrapper was interrupted (Ctrl-C / SIGTERM), the background
   ct87.prepare_data kept running as an orphan — a real problem for
   multi-hour prep jobs where the operator thought they stopped the run.
   Now traps INT/TERM and forwards SIGTERM to $PID, then lets the
   existing monitor loop see the child die and run the normal
   footer/exit path. Trap disarms itself on first fire so a second
   Ctrl-C can force immediate wrapper exit.

3. CSV-schema-valid footer in run_prep_with_rss.sh (CodeRabbit, minor):
   the old `EXIT=$RC TS=...` footer wasn't a valid row for the declared
   6-column header, so csv.DictReader / pandas.read_csv would choke on
   the last line. Now emits `ts,,,,,EXIT:<rc>` — 6 fields matching the
   header, with the exit code in the `state` column (distinguishable
   from normal single-char process states R/S/D/Z).

4. seq_len + batch_size validation in make_hf_dataloader (CodeRabbit,
   outside-diff, minor): seq_len=0 would slip through as window=1 and
   yield degenerate (batch, 1) batches; batch_size=0 would later crash
   in torch.stack on an empty list. Now fails fast with clear ValueErrors
   at function entry. Two new unit tests.

All 20 prepare_data + dataloader-guard tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jenglund
Copy link
Copy Markdown
Contributor Author

Round 3 of review feedback addressed — commit c35eca55

All 4 items from the latest CodeRabbit pass handled.

1. Scenario A workload consistency (minor) ✅

Scenario A in diag_memory.py was counting empty/whitespace rows, while B/C/D skipped them. That meant n_docs represented different workloads across scenarios and the RSS comparison drifted with how many blanks happened to land early in the stream. All four scenarios now apply the same if not text or not text.strip(): continue filter.

2. Signal forwarding in run_prep_with_rss.sh (major) ✅

Real leak risk on multi-hour preps: a bare Ctrl-C on the wrapper exited the shell but left the background ct87.prepare_data running as an orphan, still consuming CPU + network + tokenizing into nowhere. Now:

_forward_signal() {
    trap - INT TERM   # disarm so a second signal force-exits immediately
    if [[ -n "${PID:-}" ]]; then
        kill -TERM "$PID" 2>/dev/null || true
    fi
}
trap _forward_signal INT TERM

After the trap forwards SIGTERM to the child, the existing monitor loop sees the child die, wait captures the exit code (e.g. 143 for SIGTERM), and the CSV footer / DONE line / final exit all run normally — the operator ends up with a complete artifact set rather than two orphaned processes.

3. CSV schema-valid footer (minor) ✅

The old EXIT=$RC TS=... footer wasn't a valid 6-column row, so pandas.read_csv / csv.DictReader would choke on the last line (or silently drop it). Now emits ts,,,,,EXIT:<rc> — 6 fields matching the header, with the exit code placed in the state column. The EXIT: prefix keeps it distinguishable from normal single-char process states (R/S/D/Z).

4. seq_len + batch_size validation in make_hf_dataloader (outside-diff, minor) ✅

  • seq_len=0 would slip through as window=1 and yield degenerate (batch, 1) windows — no error, just garbage
  • batch_size=0 would eventually crash inside torch.stack([]) with a cryptic stack expects a non-empty TensorList

Both now fail fast at function entry with clear ValueError messages. Two new unit tests under TestHfDataloaderGuard.

Test summary

20/20 tests pass across test_prepare_data.py + test_train.py::TestHfDataloaderGuard:

  • 5 in TestConcatenateAndChunk
  • 4 in TestSplitChunks
  • 4 in TestParameterValidation (seq_len / val_fraction)
  • 2 network end-to-end
  • 5 in TestHfDataloaderGuard (too-few-tokens, zero-seq-len, zero-batch-size, DatasetDict, zero-rows)

Still haven't touched the training hot path or on-disk format, so the earlier smoke-test result (71 min, 16.8 GB VRAM peak, train_loss=5.98 / val_loss=6.02) stands. Happy to re-smoke on request.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c35eca5. Configure here.

train_np = chunks_np
val_np = None
num_train = int(train_np.shape[0])
num_val = int(val_np.shape[0]) if val_np is not None else 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Production chunking/splitting logic lacks dedicated unit tests

Low Severity

run_prepare_data no longer calls chunk_stream, concatenate_and_chunk, or split_chunks — it uses inline numpy operations (np.frombuffer / reshape / slice) for chunking and splitting. However, the unit tests in TestConcatenateAndChunk and TestSplitChunks still only exercise the old list-based helpers, which are now dead code in the production path. The actual production chunking/splitting logic has no dedicated unit-level coverage (only the end-to-end @pytest.mark.network tests exercise it). The old functions could be removed or the tests redirected to cover the new numpy-based logic.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c35eca5. Configure here.

@jenglund
Copy link
Copy Markdown
Contributor Author

🎉 Phase 2 pretraining complete

Full 7800-step run of 474M target model on FineWeb-Edu-3B finished cleanly.

Criterion Spec target Actual
7800 steps without NaN/OOM Training complete. Final checkpoint at step 7800
Final val_loss ≥ 3.0-3.5 3.23
Archival checkpoints 6 (1500/3000/4500/6000/7500/7800) ✓ all present
Rolling resumable 2 retained checkpoint.pt + checkpoint_prev.pt
Wall time 6 days conservative 43.5h ≈ 1.8 days (3.3× faster — Flash Attention + Ada BF16 tensor cores)
VRAM peak <18 GB (spec) ~17.3 GB steady (no PCIe spillover)
Swap pressure 0 1.8 MB peak (noise)

Config adjustment from spec: launched with B=8 grad_accum=16 + --gradient-checkpoint (same 262144-token effective batch as spec's B=32 grad_accum=4). Required because the corrected 474M model + its activations don't fit a 4090's 24 GB at B=32. Fits cleanly in dedicated VRAM at B=8+GC, no PCIe overflow.

Loss trajectory (train / val at each archival):

  • step 1500: 4.41 / 4.48
  • step 3000: 3.80 / 3.74
  • step 4500: 3.46 / 3.46
  • step 6000: 3.31 / 3.36
  • step 7500: 3.20 / 3.31
  • step 7800: — / 3.23 (final)

Val loss dipped slightly in the late steps (3.31 → 3.36 → 3.23) as the lr-decay schedule kicked in at ~step 7000 (1.62e-4 → 1.15e-4 → 5.4e-5 → 0 linear to step 7800). Loss curve is clean — no divergence, no plateau before decay, no overfitting signal.

On-disk artifacts in training/checkpoints/harmony_474m_v1/ — 33 GB total:

  • 6 × model_step_*.safetensors (1.9 GB each)
  • 6 × optimizer_step_*.pt (2.1 GB each)
  • checkpoint.pt + checkpoint_prev.pt (rolling resumable)
  • train.csv (51 KB)

Ready for ZEB-138's 2×2 scale + teacher-match matrix.

PR test-plan checkbox update

  • Full pretraining run (Step 2) — complete, final val_loss 3.23

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:XL This PR changes 500-999 lines, ignoring generated files and removed size:XL This PR changes 500-999 lines, ignoring generated files labels Apr 27, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

User description

Summary

Four independent memory bugs collectively blocked ZEB-137 Step 0 (FineWeb-Edu-3B prep) and the downstream smoke test. Each surfaced as the one before it was removed; all four are fixed in this PR.

The four bugs

1. prepare_data.pylist[int] token accumulation

Python lists of ints cost ~36 B/token (28 B PyLong + 8 B slot). At 3 B tokens that's 108 GB. Fixed with array.array('H') (2 B/token; Mistral vocab 32000 fits uint16 with a loud-fail assert for larger vocabs).

2. prepare_data.py — glibc + pyarrow pool retention

Even after Python frees per-document tokenizer.encode() allocations, glibc holds freed segments in its arenas (~33 B/token leaked fragmentation) and pyarrow retains streamed shard chunks in its own pool (~1 GB). Without draining, RSS climbs past 7 GB at 100 M tokens; at 3 B it OOMs. Fixed with gc.collect() + pa.default_memory_pool().release_unused() + libc.malloc_trim(0) every 10k docs — keeps RSS flat at ~2.6 GB steady state.

3. prepare_data.pyDataset.from_dict materialization + int32 offset overflow

Dataset.from_dict(arr_2d).save_to_disk() materializes the full dataset in memory during conversion (needs >2× data size), spiking 29 GB peak at 3 B tokens. The default variable-length ListArray path ALSO uses pa.int32() offsets, overflowing when n_rows × seq_len > 2^31 ≈ 2.15 B — the 3 B-token prep produces ~2.99 B items and fails with ArrowInvalid: Value 2147483648 too large to fit in C integer type. Fixed with a pa.ipc.RecordBatchStreamWriter writing 10k-row batches directly. Schema is FixedSizeListArray (no offsets at all; also ~0.05% more compact on disk). Manually emit the dataset_info.json and state.json sidecars that load_from_disk expects. Peak per-batch memory bounded at ~80 MB.

4. train.py::make_hf_dataloader — same list[int] bug, different code path

for example in dataset: all_tokens.extend(example["input_ids"])torch.tensor(all_tokens, dtype=torch.long). Same 36 B/token overhead, OOM at the same threshold. Fixed with dataset.data.column("input_ids").chunks[*].values.to_numpy(zero_copy_only=True)np.concatenate into an int32 buffer → torch.from_numpy. Batches cast to int64 at yield time for nn.Embedding compatibility. Handles both FixedSizeList (new prep output) and legacy variable-length List columns.

Validation

  • Tests: 11 prepare_data tests pass; 2 make_hf_dataloader compat tests pass. The 6 pre-existing CSV-column-count failures (TestCsvLogging, TestOverfit, TestGradientAccumulation) exist on HEAD without this patch — they track against recent ι work adding forensic columns that the tests haven't been updated for.
  • End-to-end 3B prep: rc=0, 47 min wall time, peak RSS 8.99 GB on a 46 GB host (previous run before the patch hit 29 GB and OOM-killed at save). On-disk artifacts:
    • data/fineweb-edu-3b/train: 1,451,598 chunks = 2.97 B tokens, 12 GB
    • data/fineweb-edu-3b/val: 14,662 chunks = 30 M tokens, 115 MB
    • Schema: List(Value('int32'), length=2048) (FixedSizeList)
    • load_from_disk roundtrips cleanly, including row 1,000,000 (above the old int32-offset-overflow threshold)
  • Dataloader on 3B dataset: 6.5 s init, 22.7 GB peak RSS (12 GB persistent tensor + ~10 GB transient arrow page cache), batches shape (32, 2049) int64 with values in Mistral vocab range [2, 30827]. Steady-state RAM during training projects to ~18-20 GB.

Operational note

The worktree's training/.venv is a symlink to main/training/.venv, whose editable ct87 install has a hardcoded MAPPING in __editable___ct87_0_1_0_finder.py pointing at main/training/ct87/. Edits to worktree source files are silently ignored unless the MAPPING is redirected. Not in-code but noted here and in the session memory for future sessions.

Also included

  • training/run_prep_with_rss.sh: wraps a prep run with an RSS sampler writing CSV alongside the stdout log. Useful for any future streaming-tokenization runs.
  • training/ct87/diag_memory.py: reproduces the four memory-leak patterns (A: iterate text only, B: tokenize + discard, C: tokenize + accumulate, D: C with periodic trim) in a single script. Concrete artifact for the investigation; kept for future debugging.

Test plan

  • All prepare_data unit tests pass
  • make_hf_dataloader unit tests pass
  • Full 3B prep runs with rc=0 and expected output (47 min, 8.99 GB peak)
  • Dataloader loads 3B dataset without OOM (6.5 s init, 22.7 GB peak)
  • Smoke test (Step 1 per the spec) — passed, 71 min wall time, peak VRAM 16.8 GB, final train_loss=5.98 val_loss=6.02 (see comment for full results)
  • Full pretraining run (Step 2) — complete, 43.5h wall time, final val_loss=3.23 (within spec 3.0-3.5)

🤖 Generated with Claude Code


Note

Medium Risk
Touches the data-prep and training dataloader paths, changing in-memory representations and Arrow serialization; regressions could surface as subtle data corruption or high RAM usage during training at scale.

Overview
Enables multi-billion-token pretraining runs by reworking ct87.prepare_data and make_hf_dataloader to avoid Python list[int] token materialization and to load tokens from Arrow more efficiently.

prepare_data.py now tokenizes into an array.array('H'), periodically releases retained heap/Arrow memory during streaming, chunks via zero-copy NumPy views, and writes HF-compatible datasets directly with PyArrow streaming IPC using fixed-size lists to avoid offset overflow and large peak memory.

Adds operational tooling (ct87.diag_memory.py, run_prep_with_rss.sh) plus new unit tests for parameter validation and friendlier dataloader failure modes (bad args, DatasetDict paths, empty datasets).

Reviewed by Cursor Bugbot for commit c35eca5. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Documentation

    • Added a comprehensive Harmony pretraining design spec covering phased execution, checkpoints, budgets, monitoring, risks, and handoff notes.
  • Chores

    • Added a standalone memory-diagnostic tool and a prep-run RSS monitoring script.
    • Optimized data-preparation to use streaming Arrow outputs, typed buffers, periodic memory reclamation, and safer validation.
    • Improved training data loader to use on-disk token buffers and fail-fast validations for robustness.
  • Tests

    • Added tests for parameter validation and clearer user-facing errors for empty or mis-specified datasets.

CodeAnt-AI Description

Make large-token preprocessing and training start reliably with clear failures and lower memory use

What Changed

  • Data prep now rejects invalid seq_len and val_fraction values before doing long tokenization runs
  • Token streams are stored in a compact typed buffer and written to disk in batches, avoiding the old full in-memory dataset build and preventing large-scale overflow issues
  • Memory is drained during preprocessing so long runs keep RSS flatter instead of climbing until they fail
  • Training data loading now handles the new on-disk format directly, fails clearly for empty or multi-split datasets, and rejects invalid batch settings up front
  • Added a memory diagnostic script, RSS logging wrapper, and tests that cover the new validation and failure cases

Impact

✅ Fewer out-of-memory failures during 3B-token prep
✅ Faster startup for invalid training and preprocessing configs
✅ Clearer errors when loading the wrong dataset path or an empty dataset

🔄 Retrigger CodeAnt AI Review

Details

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

Sequence Diagram

This PR changes how FineWeb Edu data is preprocessed and consumed by training, switching to a memory efficient streaming pipeline and a flat int32 token buffer that supports multi billion token datasets.

sequenceDiagram
    participant Operator
    participant DataPrep
    participant HFData as HF dataset API
    participant Storage
    participant Training
    participant Dataloader

    Operator->>DataPrep: run_prepare_data(output_dir, seq_len, val_fraction, max_tokens)
    DataPrep->>DataPrep: Validate parameters and initialize tokenizer and token buffer
    DataPrep->>HFData: Stream documents from FineWeb Edu
    HFData-->>DataPrep: Texts to tokenize and append with EOS into flat token stream
    DataPrep->>Storage: Write train and val Arrow datasets plus metadata

    Training->>Dataloader: make_hf_dataloader(data_path, seq_len, batch_size)
    Dataloader->>Storage: Load Arrow dataset from disk
    Dataloader->>Dataloader: Build flat int32 token tensor and sample random windows
    Dataloader-->>Training: Yield infinite batches of token windows for training
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:XL This PR changes 500-999 lines, ignoring generated files and removed size:XL This PR changes 500-999 lines, ignoring generated files labels Apr 27, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

User description

Summary

Four independent memory bugs collectively blocked ZEB-137 Step 0 (FineWeb-Edu-3B prep) and the downstream smoke test. Each surfaced as the one before it was removed; all four are fixed in this PR.

The four bugs

1. prepare_data.pylist[int] token accumulation

Python lists of ints cost ~36 B/token (28 B PyLong + 8 B slot). At 3 B tokens that's 108 GB. Fixed with array.array('H') (2 B/token; Mistral vocab 32000 fits uint16 with a loud-fail assert for larger vocabs).

2. prepare_data.py — glibc + pyarrow pool retention

Even after Python frees per-document tokenizer.encode() allocations, glibc holds freed segments in its arenas (~33 B/token leaked fragmentation) and pyarrow retains streamed shard chunks in its own pool (~1 GB). Without draining, RSS climbs past 7 GB at 100 M tokens; at 3 B it OOMs. Fixed with gc.collect() + pa.default_memory_pool().release_unused() + libc.malloc_trim(0) every 10k docs — keeps RSS flat at ~2.6 GB steady state.

3. prepare_data.pyDataset.from_dict materialization + int32 offset overflow

Dataset.from_dict(arr_2d).save_to_disk() materializes the full dataset in memory during conversion (needs >2× data size), spiking 29 GB peak at 3 B tokens. The default variable-length ListArray path ALSO uses pa.int32() offsets, overflowing when n_rows × seq_len > 2^31 ≈ 2.15 B — the 3 B-token prep produces ~2.99 B items and fails with ArrowInvalid: Value 2147483648 too large to fit in C integer type. Fixed with a pa.ipc.RecordBatchStreamWriter writing 10k-row batches directly. Schema is FixedSizeListArray (no offsets at all; also ~0.05% more compact on disk). Manually emit the dataset_info.json and state.json sidecars that load_from_disk expects. Peak per-batch memory bounded at ~80 MB.

4. train.py::make_hf_dataloader — same list[int] bug, different code path

for example in dataset: all_tokens.extend(example["input_ids"])torch.tensor(all_tokens, dtype=torch.long). Same 36 B/token overhead, OOM at the same threshold. Fixed with dataset.data.column("input_ids").chunks[*].values.to_numpy(zero_copy_only=True)np.concatenate into an int32 buffer → torch.from_numpy. Batches cast to int64 at yield time for nn.Embedding compatibility. Handles both FixedSizeList (new prep output) and legacy variable-length List columns.

Validation

  • Tests: 11 prepare_data tests pass; 2 make_hf_dataloader compat tests pass. The 6 pre-existing CSV-column-count failures (TestCsvLogging, TestOverfit, TestGradientAccumulation) exist on HEAD without this patch — they track against recent ι work adding forensic columns that the tests haven't been updated for.
  • End-to-end 3B prep: rc=0, 47 min wall time, peak RSS 8.99 GB on a 46 GB host (previous run before the patch hit 29 GB and OOM-killed at save). On-disk artifacts:
    • data/fineweb-edu-3b/train: 1,451,598 chunks = 2.97 B tokens, 12 GB
    • data/fineweb-edu-3b/val: 14,662 chunks = 30 M tokens, 115 MB
    • Schema: List(Value('int32'), length=2048) (FixedSizeList)
    • load_from_disk roundtrips cleanly, including row 1,000,000 (above the old int32-offset-overflow threshold)
  • Dataloader on 3B dataset: 6.5 s init, 22.7 GB peak RSS (12 GB persistent tensor + ~10 GB transient arrow page cache), batches shape (32, 2049) int64 with values in Mistral vocab range [2, 30827]. Steady-state RAM during training projects to ~18-20 GB.

Operational note

The worktree's training/.venv is a symlink to main/training/.venv, whose editable ct87 install has a hardcoded MAPPING in __editable___ct87_0_1_0_finder.py pointing at main/training/ct87/. Edits to worktree source files are silently ignored unless the MAPPING is redirected. Not in-code but noted here and in the session memory for future sessions.

Also included

  • training/run_prep_with_rss.sh: wraps a prep run with an RSS sampler writing CSV alongside the stdout log. Useful for any future streaming-tokenization runs.
  • training/ct87/diag_memory.py: reproduces the four memory-leak patterns (A: iterate text only, B: tokenize + discard, C: tokenize + accumulate, D: C with periodic trim) in a single script. Concrete artifact for the investigation; kept for future debugging.

Test plan

  • All prepare_data unit tests pass
  • make_hf_dataloader unit tests pass
  • Full 3B prep runs with rc=0 and expected output (47 min, 8.99 GB peak)
  • Dataloader loads 3B dataset without OOM (6.5 s init, 22.7 GB peak)
  • Smoke test (Step 1 per the spec) — passed, 71 min wall time, peak VRAM 16.8 GB, final train_loss=5.98 val_loss=6.02 (see comment for full results)
  • Full pretraining run (Step 2) — complete, 43.5h wall time, final val_loss=3.23 (within spec 3.0-3.5)

🤖 Generated with Claude Code


Note

Medium Risk
Touches the data-prep and training dataloader paths, changing in-memory representations and Arrow serialization; regressions could surface as subtle data corruption or high RAM usage during training at scale.

Overview
Enables multi-billion-token pretraining runs by reworking ct87.prepare_data and make_hf_dataloader to avoid Python list[int] token materialization and to load tokens from Arrow more efficiently.

prepare_data.py now tokenizes into an array.array('H'), periodically releases retained heap/Arrow memory during streaming, chunks via zero-copy NumPy views, and writes HF-compatible datasets directly with PyArrow streaming IPC using fixed-size lists to avoid offset overflow and large peak memory.

Adds operational tooling (ct87.diag_memory.py, run_prep_with_rss.sh) plus new unit tests for parameter validation and friendlier dataloader failure modes (bad args, DatasetDict paths, empty datasets).

Reviewed by Cursor Bugbot for commit c35eca5. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Documentation

    • Added a comprehensive Harmony pretraining design spec covering phased execution, checkpoints, budgets, monitoring, risks, and handoff notes.
  • Chores

    • Added a standalone memory-diagnostic tool and a prep-run RSS monitoring script.
    • Optimized data-preparation to use streaming Arrow outputs, typed buffers, periodic memory reclamation, and safer validation.
    • Improved training data loader to use on-disk token buffers and fail-fast validations for robustness.
  • Tests

    • Added tests for parameter validation and clearer user-facing errors for empty or mis-specified datasets.

CodeAnt-AI Description

Make large-scale data prep and training safer to run on limited memory

What Changed

  • Data prep now rejects invalid settings up front, uses a compact token buffer, and writes dataset files in smaller batches so very large corpora can finish without running out of memory
  • The prep flow now clears unused memory during streaming and records progress with a helper script that tracks RSS while the job runs
  • Training data loading now reads the saved dataset more safely, fails with clearer messages for wrong inputs or empty datasets, and rejects invalid batch sizes or sequence lengths
  • Added tests for the new validation and error cases, plus a detailed design/spec document for the 474M pretraining plan

Impact

✅ Fewer out-of-memory failures during 3B-token prep
✅ Clearer errors for invalid training and prep settings
✅ Safer loading of prepared datasets for long training runs

🔄 Retrigger CodeAnt AI Review

Details

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

Sequence Diagram

This PR changes how FineWeb-Edu is tokenized and stored, and how the training loop loads it, to support 3B token corpora with bounded memory by streaming into a compact Arrow format and building a flat token tensor for random-window batching.

sequenceDiagram
    participant Operator
    participant PrepPipeline
    participant HFSource as HF dataset API
    participant Storage
    participant TrainLoop
    participant Model

    Operator->>PrepPipeline: Run prepare_data with seq_len, max_tokens, val_fraction
    PrepPipeline->>HFSource: Stream FineWeb Edu documents
    PrepPipeline->>PrepPipeline: Tokenize to uint16 buffer with EOS and periodically release heap
    PrepPipeline->>Storage: Chunk buffer, split train and val, write Arrow datasets with fixed size lists
    Operator->>TrainLoop: Run train with target config and prepared train and val paths
    TrainLoop->>TrainLoop: Initialize dataloader that builds flat int32 token tensor from Arrow chunks
    TrainLoop->>TrainLoop: Sample random windows of seq_len plus one tokens and cast batches to int64
    TrainLoop->>Model: Feed batches for forward and backward training steps
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

PR Code Suggestions ✨

Latest suggestions up to commit c35eca5
CategorySuggestion                                                                                                                                    Severity
Logic error
An interrupted sleep causes premature script termination under errexit, skipping normal child shutdown and final logging

With set -e enabled, sleep can return a non-zero status when interrupted by
INT/TERM, which makes the wrapper exit immediately before wait runs and before the
final EXIT: row is written. Make the sleep interruption non-fatal so the script can
always reach the controlled shutdown path.

training/run_prep_with_rss.sh [64]

Why it matters? 🤔
  • ⚠️ RSS CSV lacks final EXIT: row for interrupted runs.
  • ⚠️ Downstream analysis cannot distinguish cancel vs crash outcomes.
Steps of Reproduction ✅
1. Run the wrapper script `training/run_prep_with_rss.sh` with valid arguments (e.g., `./training/run_prep_with_rss.sh /tmp/out 1000000`) so it starts `ct87.prepare_data` in the background at lines 27–33 and records logs to `training/logs/prep_<tag>.log`.

2. Observe that the script has `set -eu` enabled at line 8 and that it enters the sampling loop at line 51 (`while kill -0 "$PID" 2>/dev/null; do`) where it periodically reads `/proc/$PID/status` and then executes `sleep "$INTERVAL"` at line 64 between samples.

3. While the script is running and currently in the `sleep "$INTERVAL"` call at line 64, send SIGINT from the controlling terminal by pressing Ctrl-C; the shell receives SIGINT, runs the `_forward_signal` trap handler defined at lines 39–44 and installed at line 45, which forwards `TERM` to the child process.

4. Because `sleep` is interrupted by SIGINT, it exits with a non-zero status, and under `set -e` at line 8 this non-zero exit from `sleep "$INTERVAL"` (a simple command in the loop body) causes the wrapper to terminate immediately, so the script never reaches the `wait "$PID"` block at lines 68–72 nor the final `EXIT:<rc>` CSV write at line 75, leaving the RSS CSV without a terminal exit-row despite the child having been signaled.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** training/run_prep_with_rss.sh
**Line:** 64:64
**Comment:**
	*Logic Error: With `set -e` enabled, `sleep` can return a non-zero status when interrupted by INT/TERM, which makes the wrapper exit immediately before `wait` runs and before the final `EXIT:<rc>` row is written. Make the sleep interruption non-fatal so the script can always reach the controlled shutdown path.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Major

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

CodeAnt AI finished running the review.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

PR Code Suggestions ✨

Latest suggestions up to commit c35eca5
CategorySuggestion                                                                                                                                    Severity
Possible bug
Running the module from the wrong working directory can make the training package unimportable at runtime

The script changes into the repository root and then runs python -m
ct87.prepare_data, but ct87 lives under training/ and is typically run from that
directory. Without installing the package into the venv, this will fail with
ModuleNotFoundError. Run from training/ (or set PYTHONPATH/use an installed package
path) before invoking the module.

training/run_prep_with_rss.sh [16-17]

Why it matters? 🤔
  • ❌ RSS-wrapped FineWeb-Edu prep fails with ModuleNotFoundError.
  • ⚠️ Blocks documented 3B-token preprocessing workflow via new wrapper.
Steps of Reproduction ✅
1. Observe package layout: `ct87` exists only as a source package under `training/ct87` (verified via `ls /workspace/harmony/training`, which shows a `ct87` directory, and `/workspace/harmony/training/ct87/prepare_data.py` lines 1–12 define the module).

2. Note the documented usage in `/workspace/harmony/training/ct87/prepare_data.py:7-11`, which explicitly says:  

   `Run from the training/ directory:` followed by `python3 -m ct87.prepare_data --output ../data/fineweb-edu-poc ...`, implying reliance on `training/` being on `sys.path` rather than an installed `ct87` package.

3. Also see `/workspace/harmony/docs/findings/2026-04-18-harmony-teacher-oracle-validation.md:147-148`, which documents running another module similarly via `cd ~/work/zeblithic/harmony/training` then `.venv/bin/python -m ct87.generate_oracle_table`, again indicating a workflow that runs modules from `training/` with source layout, not via an installed package.

4. From the repo root `/workspace/harmony`, run the new wrapper (added in this PR) as `bash training/run_prep_with_rss.sh /tmp/out 1000000`. The script at `training/run_prep_with_rss.sh:16-17` sets `WT="$(cd "$(dirname "$0")/.." && pwd)"` and `cd "$WT"`, so it executes in the repo root, then invokes `training/.venv/bin/python -u -m ct87.prepare_data` at line 27. In an environment matching the documented workflow (where `ct87` lives only under `training/ct87` and has not been installed into `training/.venv`), Python's import machinery does not see a top-level `ct87` package from the repo root, and the process fails immediately with `ModuleNotFoundError: No module named 'ct87'` before any data prep or RSS sampling occurs.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** training/run_prep_with_rss.sh
**Line:** 16:17
**Comment:**
	*Possible Bug: The script changes into the repository root and then runs `python -m ct87.prepare_data`, but `ct87` lives under `training/` and is typically run from that directory. Without installing the package into the venv, this will fail with `ModuleNotFoundError`. Run from `training/` (or set `PYTHONPATH`/use an installed package path) before invoking the module.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Critical

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

CodeAnt AI finished running the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant