test_kv_bindings.py 10.7 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.


import asyncio
18
19
import json
import threading
20
21
22
23
from typing import List

import pytest

24
from dynamo.llm import ApproxKvIndexer, KvEventPublisher, KvIndexer, RadixTree
25
from dynamo.runtime import Component, DistributedRuntime
26
27
28
29

pytestmark = pytest.mark.pre_merge


30
@pytest.fixture
31
async def distributed_runtime():
32
    """Function-scoped runtime fixture for distributed runtime tests."""
33
    loop = asyncio.get_running_loop()
34
    runtime = DistributedRuntime(loop, "etcd", "nats")
35
36
    yield runtime
    runtime.shutdown()
37

38

39
@pytest.mark.asyncio
Yan Ru Pei's avatar
Yan Ru Pei committed
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
async def test_radix_tree_binding(distributed_runtime):
    """Test RadixTree binding directly with store event and find matches"""
    import json

    # Create RadixTree instance
    radix_tree = RadixTree()

    # Create a store event with parent_hash=None, block_hash=0
    # Following the KvCacheEvent format from the Rust protocols
    store_event = {
        "event_id": 1,
        "data": {
            "stored": {
                "parent_hash": None,
                "blocks": [
                    {
                        "block_hash": 0,
                        "tokens_hash": 0,  # Using 0 for both hashes to match tokens [0]
                    }
                ],
            }
        },
    }

    # Convert to JSON bytes
    event_bytes = json.dumps(store_event).encode("utf-8")

    # Apply the event to worker_id 0
    worker_id = 0
    radix_tree.apply_event(worker_id, event_bytes)

    # Find matches for tokens [0]
    # The sequence parameter expects token hashes, so we use [0] to match tokens_hash=0
    overlap_scores = radix_tree.find_matches([0])

    # Verify the results
Yan Ru Pei's avatar
Yan Ru Pei committed
76
    # Note: scores is now Dict[(worker_id, dp_rank), score]
Yan Ru Pei's avatar
Yan Ru Pei committed
77
78
79
80
    assert overlap_scores.scores is not None
    assert (
        len(overlap_scores.scores) == 1
    ), f"Expected 1 worker in scores, got {len(overlap_scores.scores)}"
Yan Ru Pei's avatar
Yan Ru Pei committed
81
    worker_key = (worker_id, 0)  # (worker_id, dp_rank)
Yan Ru Pei's avatar
Yan Ru Pei committed
82
    assert (
Yan Ru Pei's avatar
Yan Ru Pei committed
83
84
85
86
87
        worker_key in overlap_scores.scores
    ), f"Worker {worker_key} not found in scores"
    assert (
        overlap_scores.scores[worker_key] == 1
    ), f"Expected score 1 for worker {worker_key}, got {overlap_scores.scores[worker_key]}"
Yan Ru Pei's avatar
Yan Ru Pei committed
88

89
90
91
92
93
94
95
96
97
98
99
    blocks = radix_tree.dump_tree_as_events()
    assert len(blocks) == 1, f"Expected 1 block event, got {len(blocks)}"
    json.loads(blocks[0])  # check valid json

    # cleanup
    radix_tree.remove_worker(worker_id)
    blocks_empty = radix_tree.dump_tree_as_events()
    assert (
        len(blocks_empty) == 0
    ), f"Expected 0 block events after removal, got {len(blocks_empty)}"

Yan Ru Pei's avatar
Yan Ru Pei committed
100
    print(
Yan Ru Pei's avatar
Yan Ru Pei committed
101
        f"✓ RadixTree test passed: worker {worker_key} has score {overlap_scores.scores[worker_key]}"
Yan Ru Pei's avatar
Yan Ru Pei committed
102
103
104
    )


105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
@pytest.mark.asyncio
@pytest.mark.parametrize("num_threads", [2, 3, 5, 128])
@pytest.mark.parametrize("prepopulate_worker_ids", [True, False])
@pytest.mark.parametrize("expiration_duration_secs", [None])
@pytest.mark.parametrize("is_threaded", [True, False])
async def test_radix_tree_thread_safety(
    distributed_runtime,
    num_threads,
    prepopulate_worker_ids,
    expiration_duration_secs,
    is_threaded,
):
    """Test RadixTree thread safety by applying events from multiple threads."""
    radix_tree = RadixTree(expiration_duration_secs=expiration_duration_secs)
    threads = []
    done_counter = 0
    exception_counter = 0

    def worker(worker_id, prepopulate_worker_ids: bool = False):
        try:
            nonlocal done_counter
            worker_id = worker_id
            hash = worker_id
            if prepopulate_worker_ids:
                hash = (
                    2**32 - worker_id
                )  # use different hash for prepopulate_worker_ids
            assert 0 <= hash < 2**64  # needs to be valid u64
            store_event = {
                "event_id": worker_id,
                "data": {
                    "stored": {
                        "parent_hash": None,
                        "blocks": [
                            {
                                "block_hash": hash,
                                "tokens_hash": hash,
                            }
                        ],
                    }
                },
            }
            event_bytes = json.dumps(store_event).encode("utf-8")
            radix_tree.apply_event(worker_id, event_bytes)
            if not prepopulate_worker_ids:
                done_counter += 1
        except Exception as e:
            print(f"Exception in worker {worker_id}: {e}")
            nonlocal exception_counter
            exception_counter += 1

    if prepopulate_worker_ids:
        for i in range(num_threads):
            worker(i, prepopulate_worker_ids=True)
        assert (
            exception_counter == 0
        ), f"Warmup: expected 0 exceptions, got {exception_counter}"

    for i in range(num_threads):
        if is_threaded:
            t = threading.Thread(target=worker, args=(i,))
            threads.append(t)
            t.start()
        else:
            worker(i)
    if is_threaded:
        timeout = 10  # seconds
        for t in threads:
            t.join(timeout)
            assert not t.is_alive(), "Thread timed out"
    assert exception_counter == 0, f"Expected 0 exceptions, got {exception_counter}"
    assert (
        done_counter == num_threads
    ), f"Expected {num_threads} done, got {done_counter}"

    for i in range(num_threads):
        overlap_scores = radix_tree.find_matches([i])
        assert overlap_scores.scores is not None
        worker_key = (i, 0)
        assert (
            worker_key in overlap_scores.scores
        ), f"Worker {worker_key} not found in scores"
        assert (
            overlap_scores.scores[worker_key] == 1
        ), f"Expected score 1 for worker {worker_key}, got {overlap_scores.scores[worker_key]}"
    # get all blocks
    blocks = radix_tree.dump_tree_as_events()
    expected_blocks = num_threads + (prepopulate_worker_ids * num_threads)
    assert (
        len(blocks) == expected_blocks
    ), f"Expected {expected_blocks} block events, got {len(blocks)}"
    # remove single worker
    radix_tree.remove_worker(0)
    expected_blocks_after_removal = expected_blocks - (
        2 if prepopulate_worker_ids else 1
    )
    blocks_after_removal = radix_tree.dump_tree_as_events()
    assert (
        len(blocks_after_removal) == expected_blocks_after_removal
    ), f"Expected {expected_blocks_after_removal} block events after removal, got {len(blocks_after_removal)}"


207
@pytest.mark.asyncio
208
async def test_event_handler(distributed_runtime):
209
    kv_block_size = 32
210
211
    namespace = "kv_test"
    component = "event"
212
    kv_listener = distributed_runtime.namespace(namespace).component(component)
213
214

    # publisher
215
216
217
218
    # Get actual worker_id from component (KvEventPublisher ignores the passed worker_id and uses component's connection_id)
    # Create a dummy endpoint to access connection_id since Component doesn't expose it directly
    dummy_endpoint = kv_listener.endpoint("dummy")
    worker_id = dummy_endpoint.connection_id()
219
    event_publisher = EventPublisher(kv_listener, worker_id, kv_block_size)
220
221

    # indexer
222
    indexer = KvIndexer(kv_listener, kv_block_size)
223

224
    test_token = [3] * kv_block_size
225
226
227
228
229
    lora_id = 0  # lora_id is not used in the indexer
    scores = await indexer.find_matches_for_request(test_token, lora_id)
    assert not scores.scores

    event_publisher.store_event(test_token, lora_id)
230
231
232
233
    # Wait for the event to be processed (sent asynchronously)
    await asyncio.sleep(0.2)

    scores = await indexer.find_matches_for_request(test_token, lora_id)
Yan Ru Pei's avatar
Yan Ru Pei committed
234
    worker_key = (worker_id, 0)  # (worker_id, dp_rank)
235
236
237
238
239
240
241
    assert scores.scores, "No scores found"
    assert worker_key in scores.scores, f"Worker {worker_key} not found in scores"
    assert (
        scores.scores[worker_key] == 1
    ), f"Expected score 1, got {scores.scores[worker_key]}"

    # Remove event and verify
242
    event_publisher.remove_event()
243
244
245
246
    await asyncio.sleep(0.2)

    scores = await indexer.find_matches_for_request(test_token, lora_id)
    assert not scores.scores, f"Scores still present: {scores.scores}"
247

248

249
@pytest.mark.asyncio
250
async def test_approx_kv_indexer(distributed_runtime):
251
    """Test ApproxKvIndexer with TTL-based block tracking"""
252
253
254
255
256
    kv_block_size = 32
    namespace = "kv_test"
    component = "approx_kv"
    kv_listener = distributed_runtime.namespace(namespace).component(component)

257
258
    # Create ApproxKvIndexer with default TTL (120s)
    indexer = ApproxKvIndexer(kv_listener, kv_block_size)
259
260
261

    tokens = [0] * (kv_block_size * 2)

262
    # Initially no matches
263
264
265
266
267
    scores = await indexer.find_matches_for_request(tokens)
    assert not scores.scores

    worker_id = 0

268
    # Process routing decision - this should add blocks to the indexer
269
270
    await indexer.process_routing_decision_for_request(tokens, worker_id)

271
    # Now we should have matches
272
273
    scores = await indexer.find_matches_for_request(tokens)
    assert scores.scores
Yan Ru Pei's avatar
Yan Ru Pei committed
274
275
    worker_key = (worker_id, 0)  # (worker_id, dp_rank)
    assert worker_key in scores.scores
276
    assert scores.scores[worker_key] == 2  # 2 blocks (tokens is 2 blocks long)
277
278


279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
class EventPublisher:
    def __init__(self, component: Component, worker_id: int, kv_block_size: int):
        self.publisher = KvEventPublisher(component, worker_id, kv_block_size)
        self.event_id_counter = 0
        self.block_hashes: List[int] = []

    def store_event(self, tokens, lora_id):
        parent_hash = self.event_id_counter if self.event_id_counter > 0 else None
        self.publisher.publish_stored(
            self.event_id_counter,  # event_id
            tokens,  # token_ids
            [
                len(tokens),
            ],  # num_block_tokens
            [
                self.event_id_counter,
            ],  # block_hashes
            lora_id,  # lora_id
            parent_hash,  # parent_hash
        )
        self.block_hashes.append(self.event_id_counter)
        self.event_id_counter += 1

    def remove_event(self):
        self.publisher.publish_removed(
            self.event_id_counter,  # event_id
            [
                self.block_hashes[-1],
            ],  # block_hashes
        )
        self.event_id_counter += 1