Harden L2 cache clearing against persistence reward-hacking#49
Merged
Conversation
Contributor
|
Human Bryce here: I've observed this class of reward hacking in two separate autoresearch experiments of my own (one on CCCL's histogram algorithm and one on craftax.cu). I asked an agent to check whether this was exploitable with pygpubench, thus this PR. The hole it describes matches my understanding of the issue it encountered in our runs, and the fix matches the one that I have in my codebase/harnesses. |
clear_cache() spam-writes 2*L2 of dummy data to evict the previous
iteration's footprint, and calls cudaCtxResetPersistingL2Cache() to undo
"sneaky cache persistency". That reset alone is not sufficient when a
submission also reserves a persisting L2 carveout: a kernel can mark a
reused buffer (weights, lookup tables, ...) persisting via a stream
access-policy window (cudaStreamSetAttribute /
cudaStreamAttributeAccessPolicyWindow, hitProp=cudaAccessPropertyPersisting)
backed by cudaLimitPersistingL2CacheSize. Normal/streaming writes can only
use the *non*-set-aside portion of L2, so the spam-write cannot evict lines
parked in the carveout -- the buffer stays warm across timed iterations and
the kernel is scored as if its inputs were always L2-resident.
On Hopper/Blackwell the default persisting carveout is already nonzero, so a
submission only needs to set an access-policy window (no cudaDeviceSetLimit
call) to start parking data in the set-aside region.
Before the spam-write, also:
- release the persisting carveout (cudaDeviceSetLimit(..., 0)) so the write
uses the whole L2 and can evict everything, including previously-parked
persisting lines. This is gated on the carveout actually being nonzero, so
honest kernels (which never reserve one) pay nothing after the first clear;
cudaDeviceGetLimit is cheap and non-blocking.
- clear the stream's access-policy window so the next launch does not
immediately re-mark its inputs persisting.
Adds test/l2_persistence.py, which benchmarks a memory-bound GEMV honestly
and with the persistence trick and asserts the trick confers no speedup. The
guarded advantage scales with the GPU's L2:HBM bandwidth ratio (large on
A100/H100/L4), so it is a back-to-back relative comparison on the same device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
robobryce
force-pushed
the
harden-l2-persistence
branch
from
June 7, 2026 16:19
58cd0ff to
f9fb5a0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
clear_cache()(csrc/clear_l2.cu) evicts the previous iteration's L2 footprint by spam-writing2 * L2of dummy data, and callscudaCtxResetPersistingL2Cache()to undo "sneaky cache persistency". The reset on its own is not sufficient when a submission also reserves a persisting L2 carveout. This PR closes that gap and adds a regression test.The hole
A kernel can keep a large reused input (weights, lookup tables, embedding rows, histogram bins, ...) resident in L2 across the timed iterations by marking it persisting:
Normal / streaming writes can only use the non-set-aside portion of L2, so the
write_cache_kernelspam-write cannot evict lines parked in the carveout. The reused buffer then stays warm across iterations and the kernel is timed as if its inputs were permanently L2-resident — a benchmark score that a fair cold-cache (or real one-shot) run would never see.cudaCtxResetPersistingL2Cache()is meant to neutralize this by demoting persisting lines to "normal", but it is the only thing standing between the carveout and the spam-write, and it is a host-immediate, non-stream-ordered call.Two aggravating facts I confirmed on hardware (B200, CUDA 12.8):
cudaLimitPersistingL2CacheSizeis already nonzero (23.7 MiB out of a 126 MiB L2 on the B200 I tested, in a fresh context). So a submission doesn't even need thecudaDeviceSetLimitcall — just setting a persisting access-policy window is enough to start parking data in the set-aside region.This matches documented CUDA L2-management semantics (the "Device Memory L2 Access Management" section of the CUDA C++ Programming Guide): the set-aside region is reserved for persisting accesses and is not available to normal/streaming accesses, and releasing it requires resetting
cudaLimitPersistingL2CacheSizeand/or callingcudaCtxResetPersistingL2Cache(). I verified the consequence directly on the B200 below: a plain spam-write cannot evict a set-aside persisting buffer, but releasing the carveout makes it fully cold again.The fix
Before the spam-write,
clear_cache()now also:cudaDeviceSetLimit(cudaLimitPersistingL2CacheSize, 0)) so the spam-write uses the whole L2 and can evict everything — including lines the previous kernel parked as persisting. Changing this device limit drains in-flight work, which also guarantees the previous (untrusted) kernel has finished before we demote+evict its lines. This is gated on the carveout actually being nonzero, so honest kernels pay nothing: they never reserve a carveout, so after the first clear the limit stays 0 and the branch is skipped.cudaDeviceGetLimitis cheap and non-blocking (~0.8 µs, measured).cudaCtxResetPersistingL2Cache()to demote any still-persisting lines.The change is localized to
clear_cache(), which is the single choke point used for both warmup and the timed loop.Honesty about scope / severity
I want to be upfront: I could not turn this into a working end-to-end score inflation on the B200 I had access to. End-to-end through
do_bench_isolated, a warm-resident buffer (none-clear) measured the same as a cold one even for a 124 µs, L2-bandwidth-bound kernel — the B200's HBM3e is fast enough relative to its very large L2 that the warm/cold gap is small, and the stockreset + spam-writedoes in practice evict on this driver/arch.So this PR is defense-in-depth hardening of a defense that is fragile by construction, not a patch for a live B200 exploit:
cudaCtxResetPersistingL2Cache()landing effectively inside a fully pipelined loop that never synchronizes. I verified (a) that call does not synchronize the device (returns in ~5 µs while a ~1 s kernel runs) and (b) with the device idle, a plain spam-write evicts only ~50–65% of a set-aside persisting buffer — the reset is doing the rest, unordered.sm_80;90, the wheels this repo ships) and on the L4 used in this repo's CI than on a B200. The fix removes the reliance on reset ordering entirely on those parts.Microbenchmark evidence (B200, device-idle, isolates the eviction physics)
A standalone test that pins a reused buffer persisting, primes it L2-resident, applies a clear, then times one bandwidth-bound read (warm/cold gap ~1.7x at 64 MiB on this B200):
So the spam-write alone cannot evict a set-aside persisting buffer; releasing the carveout makes it cold-cache again, independent of reset ordering.
Test
test/l2_persistence.pybenchmarks the same memory-bound GEMV honestly and with the persistence trick, and asserts the trick yields no warm-cache speedup (hacked median ≥ 0.88 × honest median). It's a back-to-back relative comparison on whatever GPU runs it, so it's most discriminating on the CI L4. Passes with this PR; correctness controls (submission_correct/submission_wronginexploits/) are unaffected (verified: correct → PASS, wrong → caught).Notes
-DCMAKE_CUDA_ARCHITECTURES=90;100). The new code inclear_cache()uses only stable CUDA runtime APIs (cudaDeviceGetLimit/cudaDeviceSetLimit/cudaCtxResetPersistingL2Cache/cudaStreamSetAttribute) plus<cstddef>/<cstring>, so it should build unchanged under the CI's CUDA 13.0 /CUDAARCHS=80;90;100f;120f.masterfails to compilecsrc/check.cu, becausecuda::ceil_divthere is now called with mismatched argument types ((size_t, int)) which no longer deduce. I worked around it locally to build/test; the 12.8 CI row is currently commented out so this PR doesn't address it, but happy to send a separate one-liner.