feat(unified-memory): byte-budget sizing, feasibility floor, and a conservation verifier (#35158)
Co-authored-by: Caihua Li <caihua.li@bytedance.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cheng Wan <cheng.wan@radixark.ai>
This commit is contained in:
co-authored by
Caihua Li
Claude Fable 5
Cheng Wan
parent
961beee9e5
commit
98cb3535b7
@@ -0,0 +1,157 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Byte-conservation verifier for the unified 2-pool composites.
|
||||
|
||||
`verify_byte_accounting` is the idle-time tripwire the token-identity leak
|
||||
check cannot provide: the unified pool's correctness rests on BYTE bookkeeping
|
||||
(watermark spans, holes, pending compaction, frontier ordering inside one
|
||||
shared buffer), and a drifted counter admits requests into memory that is not
|
||||
actually free — silent corruption territory, not a crash.
|
||||
|
||||
Derived properties pinned here:
|
||||
|
||||
* Conservation: on a lazy end pool the watermark span must equal
|
||||
live + holes + pending pages at EVERY point of a healthy lifecycle
|
||||
(alloc, partial free, group free, flush) — not just at rest.
|
||||
* The check is not vacuous: drifting any single term (live count, watermark,
|
||||
a leaked hole) reports loudly, naming the sub-pool.
|
||||
* Chain order: one member's low frontier clearing the other's high frontier
|
||||
is what "two pools share one buffer without overlap" MEANS; the pair check
|
||||
must hold regardless of which member grows up.
|
||||
* The strict escalation env defaults OFF: promoting the diagnostic to a
|
||||
RuntimeError is a validation posture, not the production one.
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_unified_byte_accounting.py -v
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from test_multi_ended_allocator import TestPagedMultiEndedAllocator as _PagedFixture
|
||||
from test_multi_ended_allocator import (
|
||||
TestUnifiedSWATokenToKVPoolAllocator as _SwaFixture,
|
||||
)
|
||||
|
||||
from sglang.srt.mem_cache import multi_ended_allocator as mea
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=15, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def _swa_composite():
|
||||
inst = _SwaFixture([m for m in dir(_SwaFixture) if m.startswith("test_")][0])
|
||||
pool, allocator, kvcache = inst._build()
|
||||
return inst, allocator, kvcache
|
||||
|
||||
|
||||
def _paged_pair(lazy: bool):
|
||||
inst = _PagedFixture([m for m in dir(_PagedFixture) if m.startswith("test_")][0])
|
||||
_pool, full, swa, _fkv, _skv = inst._build()
|
||||
full.lazy_compaction = lazy
|
||||
return full, swa
|
||||
|
||||
|
||||
class TestHealthyLifecycleReportsClean(unittest.TestCase):
|
||||
def test_swa_composite_clean_at_every_step(self):
|
||||
inst, allocator, kvcache = _swa_composite()
|
||||
self.assertEqual(allocator.verify_byte_accounting(), [])
|
||||
v = inst._alloc(allocator, kvcache, 8)
|
||||
self.assertEqual(allocator.verify_byte_accounting(), [])
|
||||
allocator.free_swa(v[:4]) # tombstone half the swa side
|
||||
self.assertEqual(allocator.verify_byte_accounting(), [])
|
||||
inst._free(allocator, kvcache, v)
|
||||
self.assertEqual(allocator.verify_byte_accounting(), [])
|
||||
allocator.clear()
|
||||
self.assertEqual(allocator.verify_byte_accounting(), [])
|
||||
|
||||
def test_lazy_end_pool_clean_through_free_and_flush(self):
|
||||
full, _swa = _paged_pair(lazy=True)
|
||||
self.assertEqual(full._byte_accounting_violations(), [])
|
||||
v = full.alloc(full.page_size * 4)
|
||||
self.assertEqual(full._byte_accounting_violations(), [])
|
||||
full.free(v[: full.page_size * 2]) # lazy: holes, no compaction yet
|
||||
self.assertEqual(full._byte_accounting_violations(), [])
|
||||
full._flush(urgent=True)
|
||||
self.assertEqual(full._byte_accounting_violations(), [])
|
||||
|
||||
|
||||
class TestDriftReportsLoudly(unittest.TestCase):
|
||||
"""Each mutation below models a distinct bookkeeping bug; the verifier
|
||||
must name the drifted sub-pool. Without these, a regression in any single
|
||||
counter passes every other test (the pool still 'works' — it just lies
|
||||
about capacity)."""
|
||||
|
||||
def _lazy_full(self):
|
||||
full, _swa = _paged_pair(lazy=True)
|
||||
v = full.alloc(full.page_size * 4)
|
||||
full.free(v[: full.page_size]) # one hole so all three terms are live
|
||||
self.assertEqual(full._byte_accounting_violations(), [])
|
||||
return full
|
||||
|
||||
def test_drifted_live_count(self):
|
||||
full = self._lazy_full()
|
||||
full.live_page_count += 1
|
||||
self.assertTrue(any("span" in s for s in full._byte_accounting_violations()))
|
||||
|
||||
def test_leaked_hole(self):
|
||||
full = self._lazy_full()
|
||||
full._free_phys_pages = full._free_phys_pages[:-1] # hole vanished
|
||||
self.assertTrue(any("span" in s for s in full._byte_accounting_violations()))
|
||||
|
||||
def test_drifted_watermark(self):
|
||||
full = self._lazy_full()
|
||||
full.watermark_physical += 1
|
||||
self.assertTrue(any("span" in s for s in full._byte_accounting_violations()))
|
||||
|
||||
def test_composite_report_names_the_sub_pool(self):
|
||||
"""Frontier-bounds drift (checked in BOTH lazy and eager modes): push
|
||||
the swa band's watermark outside the buffer."""
|
||||
inst, allocator, kvcache = _swa_composite()
|
||||
inst._alloc(allocator, kvcache, 8)
|
||||
swa = allocator.swa_attn_allocator
|
||||
# grow-down member: low frontier = (wm+1)*bytes; wm == num_pages puts
|
||||
# it past the buffer top.
|
||||
self.assertEqual(swa.grow_direction, "down")
|
||||
swa.watermark_physical = swa.num_pages
|
||||
out = allocator.verify_byte_accounting()
|
||||
self.assertTrue(out and any("[swa]" in s for s in out), out)
|
||||
|
||||
|
||||
class TestChainFrontierOrder(unittest.TestCase):
|
||||
def test_overlapping_frontiers_report(self):
|
||||
"""Both bands hold pages, then the up member's watermark is pushed past
|
||||
the down member's LIVE low frontier: the two bands now claim the same
|
||||
bytes of one buffer. (An empty down band cannot overlap — its low
|
||||
frontier IS the buffer top — so both sides must be populated for the
|
||||
scenario to be a real corruption.)"""
|
||||
full, swa = _paged_pair(lazy=False)
|
||||
chain = mea._end_pair_chain(full, swa)
|
||||
up, down = chain
|
||||
self.assertEqual(up.grow_direction, "up")
|
||||
self.assertIsNotNone(down.alloc(down.page_size * 2)) # down side live
|
||||
self.assertLess(down._byte_low_frontier(), up.unified_buffer.total_bytes)
|
||||
up.watermark_physical = up.num_pages # up band swallows the buffer
|
||||
out = mea._chain_byte_accounting_violations(chain)
|
||||
self.assertTrue(any("overlap" in s for s in out), out)
|
||||
|
||||
def test_pair_order_is_direction_agnostic(self):
|
||||
"""The factories and the unit fixtures orient the pair differently;
|
||||
the check must order by grow direction, not by argument position."""
|
||||
full, swa = _paged_pair(lazy=False)
|
||||
a = mea._end_pair_chain(full, swa)
|
||||
b = mea._end_pair_chain(swa, full)
|
||||
self.assertEqual([x.sub_pool_name for x in a], [x.sub_pool_name for x in b])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,244 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Byte-budget buffer sizing for the unified 2-pool factories.
|
||||
|
||||
Derived properties pinned here:
|
||||
|
||||
* Budget honored EXACTLY: with ``unified_total_bytes`` set, the swa pair's
|
||||
buffer is that many bytes (the mamba pair adds the state pool's bytes on
|
||||
top — the budget is captured AFTER the state carve-out). Sizing from the
|
||||
ratio-derived token counts instead re-introduces the configurator's
|
||||
rounding: the swa split floors the budget by the cell size and then
|
||||
page-aligns EACH side's token count, so the re-sum reconstructs less
|
||||
than the profiled budget by up to about one page of tokens per side.
|
||||
* Fallback: without the budget, sizing is the historical token-count re-sum,
|
||||
bit-for-bit.
|
||||
* bs=1 feasibility floor: a budget that cannot fit ONE worst-case request
|
||||
(full KV at max context, plus one SWA window / the state slots a single
|
||||
running request locks) raises at BOOT, before any pool construction —
|
||||
under-sizing is a retract LIVELOCK at runtime, not a perf bug.
|
||||
* The 4096-byte alignment exists because the factories ``.view()`` the whole
|
||||
uint8 buffer as the KV dtype; an unaligned budget must be floored, never
|
||||
rounded up (rounding up overcommits profiled memory).
|
||||
|
||||
python -m pytest test/registered/unit/mem_cache/test_unified_byte_budget_sizing.py -v
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.unified_memory_pool import (
|
||||
MambaSubPoolSpec,
|
||||
MHASubPoolSpec,
|
||||
UnifiedKVPool,
|
||||
_check_bs1_feasibility_floor,
|
||||
_reserved_floor_bytes,
|
||||
init_unified_swa_pools,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=15, suite="base-a-test-cpu")
|
||||
|
||||
_DEV = "cpu"
|
||||
|
||||
|
||||
def _swa_factory(**over):
|
||||
kw = dict(
|
||||
device=_DEV,
|
||||
kv_cache_dtype=torch.float16,
|
||||
head_num=2,
|
||||
head_dim=8,
|
||||
v_head_dim=8,
|
||||
swa_head_num=2,
|
||||
swa_head_dim=8,
|
||||
swa_v_head_dim=8,
|
||||
page_size=1,
|
||||
start_layer=0,
|
||||
end_layer=4,
|
||||
swa_attention_layer_ids=[1, 3],
|
||||
full_attention_layer_ids=[0, 2],
|
||||
full_max_total_num_tokens=64,
|
||||
swa_max_total_num_tokens=32,
|
||||
enable_memory_saver=False,
|
||||
need_sort=False,
|
||||
)
|
||||
kw.update(over)
|
||||
return init_unified_swa_pools(**kw)
|
||||
|
||||
|
||||
def _entry_bytes():
|
||||
full = MHASubPoolSpec(
|
||||
name="full",
|
||||
layer_num=2,
|
||||
head_num=2,
|
||||
head_dim=8,
|
||||
store_dtype=torch.float16,
|
||||
grow_direction="up",
|
||||
)
|
||||
return full.entry_bytes()
|
||||
|
||||
|
||||
class TestBudgetSizing(unittest.TestCase):
|
||||
def test_swa_factory_honors_the_budget_exactly(self):
|
||||
e = _entry_bytes()
|
||||
budget = 96 * e + 512 # deliberately NOT a token-count multiple
|
||||
bundle = _swa_factory(unified_total_bytes=budget)
|
||||
self.assertEqual(bundle.unified_memory_pool.total_bytes, budget)
|
||||
|
||||
def test_fallback_is_the_token_count_resum(self):
|
||||
e = _entry_bytes()
|
||||
bundle = _swa_factory()
|
||||
self.assertEqual(bundle.unified_memory_pool.total_bytes, (64 + 32) * e)
|
||||
|
||||
def test_budget_beats_resum_on_rounding(self):
|
||||
"""The property that motivates the whole phase: the re-sum cannot
|
||||
represent a budget that is not a whole-token multiple per side, so it
|
||||
strands bytes the buffer could have held."""
|
||||
e = _entry_bytes()
|
||||
budget = (64 + 32) * e + (e - 2) # almost one more entry
|
||||
bundle = _swa_factory(unified_total_bytes=budget)
|
||||
self.assertEqual(bundle.unified_memory_pool.total_bytes, budget)
|
||||
self.assertGreater(budget, (64 + 32) * e)
|
||||
|
||||
|
||||
class TestReservedFloorIsOneSourceOfTruth(unittest.TestCase):
|
||||
"""The bs=1 floor charges the slot-0 sink, and MUST charge exactly what
|
||||
`UnifiedKVPool` actually reserves.
|
||||
|
||||
Regression (GPU eval_434/436, Falcon-H1 boot): the floor hand-copied the
|
||||
formula as `page_size * max(entry_bytes)`, applying the page multiplier to
|
||||
the MAMBA spec. The pool deliberately excludes mamba (it is page_size=1),
|
||||
so with page_size=256 and a ~139 MB state entry the floor over-charged the
|
||||
sink by 256x — ~33 GiB of phantom requirement — and a healthy config
|
||||
failed to boot with 25 GiB of real headroom.
|
||||
"""
|
||||
|
||||
def _specs(self, page_size):
|
||||
full = MHASubPoolSpec(
|
||||
name="full",
|
||||
layer_num=2,
|
||||
head_num=2,
|
||||
head_dim=8,
|
||||
store_dtype=torch.float16,
|
||||
grow_direction="down",
|
||||
)
|
||||
# A state entry vastly larger than a KV token entry — the real ratio
|
||||
# (~139 MB vs ~45 KB) is what made the over-charge fatal.
|
||||
mamba = MambaSubPoolSpec(
|
||||
name="mamba",
|
||||
layer_num=2,
|
||||
conv_state_shapes=((4, 256),),
|
||||
conv_dtype=torch.float16,
|
||||
temporal_state_shape=(4, 256, 64),
|
||||
temporal_dtype=torch.float16,
|
||||
grow_direction="up",
|
||||
)
|
||||
return full, mamba
|
||||
|
||||
def test_mamba_entry_is_not_multiplied_by_page_size(self):
|
||||
full, mamba = self._specs(page_size=256)
|
||||
got = _reserved_floor_bytes([full, mamba], 256)
|
||||
self.assertEqual(got, max(mamba.entry_bytes(), 256 * full.entry_bytes()))
|
||||
self.assertLess(got, 256 * mamba.entry_bytes()) # the bug's value
|
||||
|
||||
def test_floor_sink_equals_what_the_pool_reserves(self):
|
||||
"""Pin the two against each other so the formula cannot drift again."""
|
||||
for page_size in (1, 4, 256):
|
||||
with self.subTest(page_size=page_size):
|
||||
full, mamba = self._specs(page_size)
|
||||
floor = _reserved_floor_bytes([full, mamba], page_size)
|
||||
pool = UnifiedKVPool(
|
||||
total_bytes=floor + 64 * mamba.entry_bytes(),
|
||||
sub_pool_specs=[full, mamba],
|
||||
device=_DEV,
|
||||
enable_memory_saver=False,
|
||||
page_size=page_size,
|
||||
)
|
||||
# min_slot_index is ceil(reserved_floor / entry_bytes) per side.
|
||||
for spec in (full, mamba):
|
||||
self.assertEqual(
|
||||
pool.min_slot_index(spec.name),
|
||||
-(-floor // spec.entry_bytes()),
|
||||
)
|
||||
|
||||
|
||||
class TestBs1FeasibilityFloor(unittest.TestCase):
|
||||
def test_infeasible_budget_raises_before_construction(self):
|
||||
"""The buffer cannot hold one sliding window plus the sink, so boot
|
||||
must fail loud instead of livelocking later."""
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
_swa_factory(
|
||||
unified_total_bytes=8 * _entry_bytes(),
|
||||
model_context_len=4096,
|
||||
sliding_window_size=4096,
|
||||
)
|
||||
self.assertIn("bs=1 floor", str(ctx.exception))
|
||||
self.assertIn("swa_window_kv", str(ctx.exception))
|
||||
|
||||
def test_context_longer_than_the_pool_is_not_rejected(self):
|
||||
"""REGRESSION: the floor must NOT charge the full-attention token side.
|
||||
`TpModelWorker.get_worker_info` clamps max_req_len to the pool, so a
|
||||
context far larger than the buffer is refused at admission, not a
|
||||
livelock -- and it is an ordinary way to serve a long-context model on
|
||||
one GPU. Charging it here made such configs fail at boot."""
|
||||
e = _entry_bytes()
|
||||
bundle = _swa_factory(
|
||||
unified_total_bytes=200 * e,
|
||||
model_context_len=1_000_000, # far beyond what the buffer holds
|
||||
sliding_window_size=16,
|
||||
)
|
||||
self.assertEqual(bundle.unified_memory_pool.total_bytes, 200 * e)
|
||||
|
||||
def test_feasible_config_boots_with_floor_inputs_present(self):
|
||||
e = _entry_bytes()
|
||||
bundle = _swa_factory(
|
||||
unified_total_bytes=200 * e,
|
||||
model_context_len=64,
|
||||
sliding_window_size=16,
|
||||
)
|
||||
self.assertEqual(bundle.unified_memory_pool.total_bytes, 200 * e)
|
||||
|
||||
def test_window_term_is_clamped_to_context(self):
|
||||
"""A window larger than the context must charge at most the context —
|
||||
otherwise short-context models over-raise."""
|
||||
e = _entry_bytes()
|
||||
bundle = _swa_factory(
|
||||
unified_total_bytes=200 * e,
|
||||
model_context_len=64,
|
||||
sliding_window_size=10_000, # window >> context
|
||||
)
|
||||
self.assertIsNotNone(bundle)
|
||||
|
||||
def test_floor_message_itemizes_terms(self):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
_check_bs1_feasibility_floor(
|
||||
total_bytes=10,
|
||||
floor_terms=[("a", 8), ("b", 8)],
|
||||
factory="test",
|
||||
)
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("a=8", msg)
|
||||
self.assertIn("b=8", msg)
|
||||
self.assertIn("16", msg)
|
||||
|
||||
def test_exact_floor_passes(self):
|
||||
"""Boundary: total == floor must NOT raise (>= is the contract)."""
|
||||
_check_bs1_feasibility_floor(
|
||||
total_bytes=16, floor_terms=[("a", 8), ("b", 8)], factory="test"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user