Skip to content

CircularBuffer: move added elements into their slots; compact loaded buffers - #1637

Open
lemony-fresh wants to merge 1 commit into
google-deepmind:masterfrom
lemony-fresh:replay-buffer-memory
Open

lemony-fresh wants to merge 1 commit into
google-deepmind:masterfrom
lemony-fresh:replay-buffer-memory

Conversation

@lemony-fresh

Copy link
Copy Markdown

Problem

Once a CircularBuffer is full, Add copy-assigns the new element into the slot of the
oldest one. For elements that hold vectors, such as AlphaZero's TrainInputs, a
copy-assigned std::vector keeps its existing storage whenever that is large enough, so
each slot keeps the storage of the largest element it ever held. With elements of varying
size, the buffer's memory keeps growing long after the buffer is full.

SerializableCircularBuffer::LoadBuffer adds to this: libnop reads vectors of non-integral
elements (the float observations and the policy's (action, probability) pairs) one
push_back at a time, deliberately without reserve(), which leaves them with up to twice
the capacity they need.

We ran into this training the C++ AlphaZero with a replay buffer of 458,752 positions
(4.7 GB as saved) on a game whose two players have very different numbers of legal actions
(a median of about 20 for one, about 240 for the other): the learner's heap kept growing
after the buffer was full, and right after resuming from a saved buffer the buffer held
about 45% more than its elements needed.

The overhead is a share of the buffer's own size, so it matters most where memory is
tight: on machines with 32 GB or less, where the buffer competes with everything else
running. With more memory it wastes the same share, but is easier to afford.

Change

  • CircularBuffer::Add takes the element by value and moves it into its slot, so a slot
    holds the new element's storage. Callers are unchanged: an lvalue is copied into the
    parameter, as before; an rvalue is moved.

    Why by value, not by const T&: copy-assigning into the slot is what keeps the slot's
    old storage, so the fix needs a copy into a fresh object, then a move into the slot
    (from a reference: data_[i] = T(value)). Taken by value, the caller's argument is that
    fresh object: copied from an lvalue, as before, or moved from an rvalue without any
    copy — one function instead of two overloads (const T& and T&&). Abseil's
    Tip #117 recommends this for arguments a function stores,
    and names the one trade-off, which is the point here: copy-assignment "can reuse
    existing buffers". That reuse is usually welcome, but with elements of very different
    sizes it makes every slot keep the largest. Where sizes do not vary (DQN's transitions),
    the change costs one allocation per added element, small next to producing the element.

  • SerializableCircularBuffer::LoadBuffer replaces each loaded element by a copy of itself,
    which holds only what it needs.

No new dependencies: the change uses only the standard library (std::move).

Measurements

With elements shaped like TrainInputs (6 x 15 x 15 float observations, positions
alternating between 150-330 and 10-40 legal actions) in a buffer of 2,000:

Bytes held / bytes needed, after loading After 3 more buffers' worth of Add
Before 1.45 1.72
After 1.00 1.00

The contents, samples drawn with the same seed, and the saved file are identical before and
after. In training, with the same change in our copy of the trainer, the learner's heap
stayed flat at 5.7 GB overnight; without it, it was at 6.1-6.4 GB and growing.

Tests

New:

  • circular_buffer_test: a slot that held a large vector does not keep its capacity when a
    small one replaces it.
  • serializable_circular_buffer_test: a loaded vector of 1,025 floats holds less than 1.5
    times its size in capacity.

Both new tests fail without the change and pass with it.

Run with the change (Linux aarch64, clang 21.1.8, LibTorch 2.10.0):

  • circular_buffer_test and serializable_circular_buffer_test through ctest, in the
    default build; both test programs also under AddressSanitizer and
    UndefinedBehaviorSanitizer, without a report.
  • The tests of the buffer's other users, in a Release build with LibTorch:
    torch_integration_test, torch_model_test, torch_vpnet_test (AlphaZero) and
    dqn_torch_test (DQN).
  • alpha_zero_torch_example on tic-tac-toe: 2 steps with a buffer that is full by the
    second, then resumed from its config.json to a third step — loading the saved buffer
    and replacing elements of a full one.

🤖 Generated with Claude Code

…buffers

Once full, Add copy-assigned the new element into the oldest slot, and a
copy-assigned std::vector keeps its storage when that is large enough, so each
slot kept the storage of the largest element it ever held. Add now takes the
element by value and moves it into the slot.

SerializableCircularBuffer::LoadBuffer: libnop reads vectors of non-integral
elements one push_back at a time, leaving up to twice the capacity they need;
each loaded element is now replaced by a copy of itself.
lemony-fresh added a commit to lemony-fresh/open_spiel that referenced this pull request Oct 6, 2026
…at keeps its size

- watch_run.py, the evaluation watcher: a checkpoint's ladder rating per
  side, beside a run or after it (--all); matches in the idle class, only
  with memory to spare. The load test (load_toggle.py,
  load_test_analysis.py) found it costs the trainer ~25-35% of its
  self-play, mostly efficiency: evaluate between runs.
- analyse_games.py, the game analyser: replays the actors' games
  (captures per side, first captures, openings); checked against logged
  returns and endings, and the trainer's per-step margins.
- Our trainer's replay buffer (thud/az/replay_buffer.h) moves positions
  into their slots and compacts itself after loading: upstream's kept each
  slot at the largest position it held (+72%) and loaded with 45% spare.
  replay_buffer_check: same contents, samples and file as upstream's,
  exact memory; controls. Actor and evaluator logs append.
- az_search_cost.cc, the CPU study; ladder.py's fit as a function.
- Docs: run W, G' to step 73 (the dwarfs' narrowing), the GPU plan, the
  conventions on diagnostics and safety flags, PR google-deepmind#1637.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lemony-fresh added a commit to lemony-fresh/open_spiel that referenced this pull request Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

⌂ Proxy