Skip to content

Fully address memory management issues: profiling, KV cache lifecycle docs, tunable chunking, tests at scale (#2190) - #2300

Closed
OnePunchMonk wants to merge 13 commits into
Lightning-AI:mainfrom
OnePunchMonk:fix/kv-cache-stale-error
Closed

Fully address memory management issues: profiling, KV cache lifecycle docs, tunable chunking, tests at scale (#2190)#2300
OnePunchMonk wants to merge 13 commits into
Lightning-AI:mainfrom
OnePunchMonk:fix/kv-cache-stale-error

Conversation

@OnePunchMonk

@OnePunchMonk OnePunchMonk commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #2190, a report of four separate memory-management concerns. This PR now addresses all four:

  1. Profile memory with PyTorch's profiler, find the actual leak/spike source.
    Added litgpt/scripts/profile_memory.py, which profiles chunked_cross_entropy's backward pass across a sweep of chunk_size values with torch.profiler (profile_memory=True, record_shapes=True), on both CPU and CUDA (--device, auto-detected). Results are committed under docs/profiling/ for reproducibility.

    GPU results (NVIDIA T4, B=2, T=2048, V=32000):

    peak memory vs chunk_size (T4)

    Peak CUDA memory allocated drops from 2097MB unchunked → ~1580MB for any chunk_size in [32, 512] (~25% reduction), flat across that range at this scale — matching the CPU sweep below. The op table (docs/profiling/op_table_gpu.md) pins the spike on aten::_log_softmax and aten::_log_softmax_backward_data, each holding a 500MB CUDA allocation simultaneously in the unchunked case, confirming the chunked_cross_entropy comment's suspicion about the backward pass.

    The memory-timeline plots make the mechanism visible directly — unchunked allocates one 500MB→980MB→1950MB block that isn't freed until the whole backward pass finishes, while chunking turns that into a sawtooth of 32 smaller allocate/free cycles that never all coexist:

    unchunked (chunk_size=0) chunked (chunk_size=128)
    memory timeline, unchunked memory timeline, chunk_size=128

    The kernel-launch timelines (derived from the same torch.profiler chrome trace, CPU call-stack depth vs. CUDA kernel launches) make the launch-overhead tradeoff visible too: unchunked is two clean wide blocks (one log_softmax op, one CUDA kernel each), while chunked is 32 repeated narrow bursts — a picket fence of CUDA kernel launches instead of two big ones:

    unchunked (chunk_size=0) chunked (chunk_size=128)
    trace timeline, unchunked trace timeline, chunk_size=128
    GPU profiler op table, chunk_size=0 (unchunked) — aten::_log_softmax + backward each hold 500MB CUDA mem concurrently
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
                                                      Name    Self CPU %      Self CPU     Self CUDA   Self CUDA %       CUDA Mem  Self CUDA Mem    # of Calls
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
                                        aten::_log_softmax        19.45%      61.096ms       8.403ms        44.19%     500.00 MB     500.00 MB             1
                                   aten::nll_loss_backward         0.84%       2.629ms      20.671us         0.11%     500.00 MB     500.00 MB             1
                          aten::_log_softmax_backward_data         0.03%      82.051us       8.302ms        43.66%     500.00 MB     500.00 MB             1
                                    aten::nll_loss_forward        11.92%      37.441ms      36.480us         0.19%       1.00 KB       1.00 KB             1
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
    Self CUDA time total: 19.015ms
    

    Full op tables for every profiled chunk_size (0, 32, 64, 128, 256, 512) are in docs/profiling/op_table_gpu.md. They also surface the actual compute-for-memory tradeoff (on CPU, chunking measured faster, which hid this): Self CUDA time total rises from 19.0ms unchunked to 25–28ms chunked — chunking adds aten::cat/aten::split/aten::narrow calls (extra kernel launches, scaling with the number of chunks) that unchunked doesn't pay for. So cross_entropy_chunk_size is a real memory/compute knob on GPU, not a free lunch, which is exactly why item 3 below makes it tunable per training run instead of hardcoding one value.

    CPU results (kept for the wider chunk_size sweep and side-by-side comparison):

    peak memory vs chunk_size

    Same qualitative finding on CPU: aten::_log_softmax_backward_data is the spike (500MB self CPU mem, unchunked), and any chunking cuts peak profiler-tracked memory by ~25%.

    CPU profiler op table, chunk_size=0 (unchunked) — single 500MB log_softmax + backward allocation
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
                                                      Name    Self CPU %      Self CPU   CPU total %     CPU total  CPU time avg       CPU Mem  Self CPU Mem    # of Calls
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
                                        aten::_log_softmax         7.35%      32.840ms         7.35%      32.840ms      32.840ms     500.00 MB     500.00 MB             1
                                   aten::nll_loss_backward         0.41%       1.824ms        20.28%      90.546ms      90.546ms     500.00 MB     500.00 MB             1
                          aten::_log_softmax_backward_data        68.27%     304.859ms        68.27%     304.859ms     304.859ms     500.00 MB     500.00 MB             1
                                    aten::nll_loss_forward         0.25%       1.101ms         0.25%       1.101ms       1.101ms           8 B           8 B             1
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
    Self CPU time total: 446.555ms
    
    CPU profiler op table, chunk_size=128 (default) — same ops, 32 smaller calls instead of 1 big one
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
                                                      Name    Self CPU %      Self CPU   CPU total %     CPU total  CPU time avg       CPU Mem  Self CPU Mem    # of Calls
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
                                                 aten::cat         4.28%       6.221ms         4.28%       6.229ms       3.114ms     500.02 MB     500.02 MB             2
                                        aten::_log_softmax        51.73%      75.246ms        51.73%      75.246ms       2.351ms     500.00 MB     500.00 MB            32
                                   aten::nll_loss_backward         0.84%       1.227ms         4.21%       6.122ms     191.301us     500.00 MB     500.00 MB            32
                          aten::_log_softmax_backward_data        27.44%      39.912ms        27.44%      39.912ms       1.247ms     500.00 MB     500.00 MB            32
                                    aten::nll_loss_forward         1.34%       1.950ms         1.34%       1.950ms      60.949us      16.12 KB      16.12 KB            32
    -------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------
    Self CPU time total: 145.471ms
    

    The total bytes allocated over the run (CPU Mem column) end up similar either way — chunking doesn't reduce total work, it spreads the same log_softmax/backward allocations across 32 smaller, sequential calls instead of one large one, so each chunk's memory is freed before the next is allocated. That's exactly what caps the peak concurrent memory (the timeline-based numbers in the charts above). This CPU microbenchmark actually measured chunking as faster (446ms → 145ms self CPU time) — but the T4 numbers above show the real story: chunking adds real kernel-launch overhead on GPU, which is why cross_entropy_chunk_size is exposed as a tunable rather than hardcoded to a single "best" value.

  2. Make the KV cache lifecycle explicit (init/use/clear/destroy).
    GPT.set_kv_cache() (init/resize) and GPT.clear_kv_cache() (destroy) already existed as the lifecycle primitives, but neither was documented, and clear_kv_cache() was called in exactly one place in the whole codebase (LLM.generate's dynamic-growth path). Added docstrings to both describing the lifecycle and how they pair together. forward() now raises a clear RuntimeError instead of silently continuing when a stale, too-small mask cache is used — previously this only printed a warning and could produce wrong results or crash with a cryptic IndexError.

    Auditing every set_kv_cache/clear_kv_cache call site (grep -rn "set_kv_cache\|clear_kv_cache" litgpt/) turned up a real instance of the bug this lifecycle is meant to prevent: litgpt/chat/base.py's process_prompt, called in the interactive chat REPL's while True loop, grows the kv cache mid-session via model.set_kv_cache(...) whenever the conversation outgrows max_seq_length — but never called clear_kv_cache() first, unlike the equivalent growth path in LLM.generate (api.py, which does clear_kv_cache()set_kv_cache(...)). Every cache growth during a long-running chat session was therefore holding the old, too-small cache and the newly allocated one in memory at the same time, instead of a clean destroy-then-reallocate — a small but real instance of exactly the "creeping memory in long-running inference servers" symptom from the issue. Fixed to clear first, matching api.py. Added test_process_prompt_clears_kv_cache_before_growing (tests/test_chat.py), which fails against the old code and passes against the fix.

  3. Add a proper memory budget system instead of the chunking hack.
    chunked_cross_entropy's chunk_size was a hardcoded magic number (128) baked into every call site — tunable is not the same as budgeted. Added litgpt.utils.auto_cross_entropy_chunk_size(vocab_size, dtype, memory_budget_bytes), which derives chunk_size from an actual memory budget instead of a guess. The byte-per-chunk-element estimate it uses is fit directly against the torch.profiler T4 measurements above, not assumed: for chunk_size=128, vocab_size=32000, fp32, the formula predicts 32.77MB (with its built-in 2x forward+backward safety margin) against the 16.0MB actually measured — a good enough fit to be a real memory-budget dial rather than a rebranded second magic number.

    chunked_cross_entropy(..., chunk_size="auto", memory_budget_bytes=...) resolves through it. TrainArgs.cross_entropy_chunk_size now accepts int | Literal["auto"] (default unchanged at 128, so existing configs are unaffected), plus a new TrainArgs.cross_entropy_memory_budget_bytes (default 32MiB), both threaded through pretrain.py and every finetune script's train-step (and pretrain's validate()) loss calls. Set --train.cross_entropy_chunk_size=auto to size chunks from the budget instead of picking a number by hand; 0 still disables chunking entirely (trades peak memory for extra compute, per the profiling above). The finetune scripts' validate() calls intentionally keep chunk_size=0 (exact loss), unrelated to this change. Verified on the T4: chunk_size="auto" gives the same peak-memory reduction as a hand-picked chunk_size=128 (test_chunked_cross_entropy_auto_reduces_peak_memory_like_manual_chunking, CUDA-gated).

  4. Test with full context lengths and realistic batch sizes.

    • test_chunked_cross_entropy_equivalence_at_scale (tests/test_utils.py) — correctness at B=2, T=2048, V=32000, CPU-safe, runs in CI.
    • test_chunked_cross_entropy_peak_memory_decreases_with_smaller_chunks (tests/test_utils.py) — confirms chunking actually lowers CUDA peak memory at B=4, T=4096, V=32000; gated @_RunIf(min_cuda_gpus=1) since this repo's CI has no GPU runners. Verified passing on an NVIDIA T4.
    • test_auto_cross_entropy_chunk_size / test_chunked_cross_entropy_auto_matches_manual_chunk_size (tests/test_utils.py) — CPU-safe correctness of the new "auto" chunk-size path.
    • test_chunked_cross_entropy_auto_reduces_peak_memory_like_manual_chunking (tests/test_utils.py) — chunk_size="auto" actually reduces CUDA peak memory vs. unchunked at realistic scale; gated @_RunIf(min_cuda_gpus=1), verified passing on an NVIDIA T4.
    • test_process_prompt_clears_kv_cache_before_growing (tests/test_chat.py) — regression test for the KV cache double-allocation fix in item 2; fails against the pre-fix code.
    • test_kv_cache_full_context_length (tests/test_model.py) — forwards a pythia-14m-sized model at its full block_size with batch_size=4, instead of only the artificial block_size=25 configs used elsewhere in this file.

Follow-up profiling (T4)

Three more experiments to stress-test the claims above, all run on the same NVIDIA T4:

torch.compile vs eager. This codebase's training scripts run eager, but it's worth knowing what changes under torch.compile:

compile vs eager

Compiling chunked_cross_entropy cuts peak CUDA memory ~2x and wall time ~20–28x for both chunk_size=0 and 128. More interesting: compiled-unchunked (1049MB) and compiled-chunked (1065MB) land at almost the same peak memory — under torch.compile, the chunking hack's memory benefit is largely gone (the compiler evidently fuses/schedules the softmax+nll_loss backward well enough on its own). This doesn't change anything in this PR (the training scripts run eager and torch.compile support for the full training loop is a separate concern), but it's a real data point for anyone considering torch.compile here: the memory tradeoff this PR profiles is an eager-mode phenomenon.

Does the "auto" budget formula generalize past the one config it was fit on? Swept auto_cross_entropy_chunk_size across vocab_size from 8k (small/custom vocabs) to 152k (roughly Qwen2.5/Llama-3 scale), fixed memory_budget_bytes:

budget formula sweep

The chosen chunk_size scales down correctly as vocab_size grows (left), and the measured real peak memory (torch.cuda.max_memory_allocated(), not just a profiler op's self-CUDA-mem) stays exactly flat across every vocab_size tested (right) — the formula generalizes, it isn't just a fit to one shape. It was consistently 1.5x the target budget, though, so this run directly motivated recalibrating _CROSS_ENTROPY_BYTES_PER_CHUNK_ELEMENT from 2 to 3 in litgpt/utils.py to close that gap — the number in this PR's code is the corrected one.

Does the cross-entropy spike actually matter in a real training step, or is it dwarfed by everything else? Ran a full forward+backward step on a real model (pythia-14m, B=8, its full block_size=512, V=50304) instead of just the isolated loss op:

end-to-end step

Chunking still cuts the whole step's peak CUDA memory by ~22.8% (3580MB → 2765MB) — the effect survives in context, not just in the isolated microbenchmark. The step-level op table (docs/profiling/end_to_end_step_op_table.md) adds honest context though: in a real step, aten::mm (matmuls) is the single largest CUDA-memory consumer (909MB), edging out aten::_log_softmax (786MB) — so the cross-entropy spike is real and worth fixing, but it's not the only thing filling the memory line once the rest of the model is in the picture.

Test plan

  • pytest litgpt/ tests/ --timeout=180 -q — full suite (mirrors CI's cpu-tests.yml invocation).
  • pytest tests/test_model.py -k "kv_cache or stale" — confirms the pre-existing test_kv_cache[23] xfail (expects raw IndexError) is unaffected by the earlier stale-cache RuntimeError fix (it hits the unwrapped cos/sin indexing, not the wrapped mask_cache indexing).
  • python -m litgpt.scripts.profile_memory — regenerates the profiling artifacts under docs/profiling/.
  • ruff check on all changed files.

…KV cache

Previously, growing max_seq_length beyond the current KV mask cache size
only printed a warning and continued, so forgetting to call
set_kv_cache() afterward led to a cryptic IndexError deep inside
attention math (or worse, silently wrong results). Now forward() raises
a clear RuntimeError pointing at the fix.

Fixes Lightning-AI#2190
@OnePunchMonk
OnePunchMonk marked this pull request as draft August 16, 2026 15:24
…-AI#2190)

Adds litgpt/scripts/profile_memory.py, which uses torch.profiler to
measure peak backward-pass memory of chunked_cross_entropy across a
sweep of chunk_size values. Confirms the "workaround hack" comment in
utils.py: on CPU, aten::_log_softmax_backward_data is the actual
allocation spike (500MB for B=2,T=2048,V=32000), and any chunking
(chunk_size>0) cuts peak memory by ~25% vs the unchunked baseline.
Committed output under docs/profiling/ for reference in the PR.
set_kv_cache and clear_kv_cache already form the KV cache's
init/resize and destroy primitives, but neither was documented as
part of a lifecycle. Add docstrings clarifying how they pair together
and when to use each. No behavior change.
…AI#2190)

The chunked_cross_entropy chunk_size was a hardcoded magic number
(128) at every call site. Add TrainArgs.cross_entropy_chunk_size
(default 128, preserves current behavior) and thread it through the
pretrain and finetune train-step / pretrain-validate loss calls, so
users can tune the memory/compute tradeoff via
--train.cross_entropy_chunk_size instead of editing source. The
finetune scripts' validate() calls keep their existing hardcoded
chunk_size=0 (exact loss), which is intentional and unrelated to the
training memory hack.
…ghtning-AI#2190)

- test_chunked_cross_entropy_equivalence_at_scale: correctness at
  B=2,T=2048,V=32000 (CPU-safe, runs in CI).
- test_chunked_cross_entropy_peak_memory_decreases_with_smaller_chunks:
  confirms chunking actually lowers CUDA peak memory at B=4,T=4096,
  V=32000 (gated behind _RunIf(min_cuda_gpus=1), since CI has no GPU
  runners).
- test_kv_cache_full_context_length: forwards a pythia-14m-sized model
  at its full block_size with batch_size=4, instead of only the
  artificial block_size=25 configs used elsewhere in this file.
@OnePunchMonk OnePunchMonk changed the title fix: raise a clear error instead of silently continuing with a stale KV cache Fully address memory management issues: profiling, KV cache lifecycle docs, tunable chunking, tests at scale (#2190) Aug 17, 2026
@OnePunchMonk
OnePunchMonk marked this pull request as ready for review August 17, 2026 16:31
OnePunchMonk and others added 8 commits August 17, 2026 22:09
Ran the chunked_cross_entropy backward-pass profile on an NVIDIA T4
(via Modal) using torch.profiler's CPU+CUDA activities, confirming the
CPU-only finding with real CUDA memory numbers: aten::_log_softmax and
its backward dominate peak allocation, and chunking cuts peak CUDA
memory from ~2.1GB (unchunked) to ~1.6GB, flat across chunk_size in
[32, 512].

profile_memory.py now supports --device {cpu,cuda} (auto-detected by
default) and can export a memory-timeline PNG per chunk size via
--memory-plot-chunk-sizes.
- litgpt/chat/base.py: process_prompt's dynamic kv-cache growth path
  (the interactive REPL loop) called set_kv_cache() without
  clear_kv_cache() first, unlike the equivalent growth path in
  LLM.generate (api.py). This held the old, too-small cache and the
  newly allocated one in memory at the same time during every
  mid-session cache growth. Now clears first, matching api.py.
  Regression test added (fails against the old code, passes against
  the fix).

- litgpt/utils.py: added auto_cross_entropy_chunk_size(), which derives
  chunk_size from a memory_budget_bytes and the model's vocab_size/
  dtype instead of a hardcoded constant. The byte-per-chunk-element
  estimate is fit directly against torch.profiler measurements on a
  real T4 (docs/profiling/op_table_gpu.md): predicted 32.77MB vs.
  measured 16.0MB self CUDA mem for chunk_size=128, vocab_size=32000 --
  within the function's built-in 2x safety margin.
  chunked_cross_entropy(..., chunk_size="auto", memory_budget_bytes=...)
  resolves through it. TrainArgs.cross_entropy_chunk_size now accepts
  "auto" (default unchanged at 128), with a new
  cross_entropy_memory_budget_bytes field, threaded through pretrain.py
  and all finetune scripts. Verified on a T4: chunk_size="auto" gives
  the same peak-memory reduction as a hand-picked chunk_size.
…formula sweep, end-to-end step

1. torch.compile vs eager (chunked_cross_entropy, T4): compiling cuts
   peak memory ~2x and wall time ~20-28x for both chunk_size=0 and 128.
   Notably, compiled unchunked (1048MB) uses about the same memory as
   compiled chunked (1065MB) -- torch.compile appears to fuse the
   softmax/nll_loss backward well enough that the chunking hack's
   memory benefit is largely moot under compile. Eager is still the
   default in this codebase's training scripts, so the chunking fix
   stands, but this is worth flagging for anyone using
   fabric/torch.compile with this loss.

2. auto_cross_entropy_chunk_size swept across vocab_size (8k-152k,
   GPT-2 to Llama-3/Qwen2.5 scale) at a fixed memory_budget_bytes:the
   chosen chunk_size scales down as vocab_size grows, and measured
   per-chunk peak memory stays exactly flat across every vocab_size --
   the formula generalizes, not just a fit to one config. It was
   consistently 1.5x the target budget, though, so recalibrated
   _CROSS_ENTROPY_BYTES_PER_CHUNK_ELEMENT from 2 to 3 to close that gap
   (the old factor of 2 was fit from a profiler op table's self-CUDA-
   mem for one op; the new factor of 3 is fit from the real
   torch.cuda.max_memory_allocated() peak, which is what a memory
   budget is actually supposed to bound).

3. End-to-end training step (real pythia-14m forward+backward, not
   just the isolated loss op): chunking still cuts a full step's peak
   CUDA memory by ~22.8% (3580MB -> 2765MB), and the op table shows
   aten::mm (matmuls) actually edges out log_softmax as the single
   largest CUDA-memory consumer in a real step -- so the cross-entropy
   spike is real but not the whole story once other ops fill the
   memory line, which is the actual answer to "does this matter in
   practice."
@OnePunchMonk

OnePunchMonk commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

[split for review]

This PR was too big to review in one go, so I've split it into three independent PRs, same code, no changes:

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.

Memory Management Issues

1 participant