Skip to content
Merged
75 changes: 43 additions & 32 deletions cpp/tensorrt_llm/batch_manager/microBatchScheduler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ namespace tensorrt_llm::batch_manager

using SizeType32 = MicroBatchScheduler::SizeType32;

/// Return the forward-pass token cost for a context chunk with KV cache reuse.
///
/// setPrepopulatedPromptLen shifts the chunk window right by the prepopulated
/// amount rather than shrinking it. For non-last chunks the model still
/// processes approximately @p chunkSize tokens; only for the last chunk is the
/// cost @p contextRemaining - @p reusable.
static SizeType32 reuse_adjusted_compute(SizeType32 chunkSize, SizeType32 reusable, SizeType32 contextRemaining)
{
if (reusable <= 0)
{
return chunkSize;
}
if (reusable + chunkSize < contextRemaining)
{
return chunkSize;
}
return std::max<SizeType32>(0, contextRemaining - reusable);
}
Comment thread
lancelly marked this conversation as resolved.

MicroBatchScheduler::MicroBatchScheduler(std::optional<batch_scheduler::ContextChunkingConfig> ctxChunkConfig,
std::optional<SizeType32> maxContextLength, LlmRequestState noScheduleUntilState,
LlmRequestState noScheduleAfterState)
Expand All @@ -38,16 +57,15 @@ void MicroBatchScheduler::fitDraftTokens(RequestVector& contextsToBeChunked,
std::optional<SizeType32> ctxTokensCapacity, SizeType32 const chunkUnitSize,
std::optional<SizeType32> const& maxContextLength)
{
// How many compute tokens (chunk - reusable) are in this batch already?
// How many compute tokens are in this batch already?
SizeType32 numCtxTokens{0};
for (auto const& llmReq : contextsToBeChunked)
{
SizeType32 const chunkSize = llmReq->getContextChunkSize();
// contextRemaining = P for first chunk; used to compute actual model token count.
SizeType32 const contextRemaining = llmReq->getContextRemainingLength();
SizeType32 const reusable
= llmReq->isFirstContextChunk() ? std::min(llmReq->getEstimatedReusableTokens(), contextRemaining) : 0;
numCtxTokens += std::min(chunkSize, std::max<SizeType32>(0, contextRemaining - reusable));
numCtxTokens += reuse_adjusted_compute(chunkSize, reusable, contextRemaining);
Comment thread
longlee0622 marked this conversation as resolved.
}

// Discard draft tokens that won't fit into the existing chunk unit, max
Expand Down Expand Up @@ -125,14 +143,13 @@ void MicroBatchScheduler::setCtxRequestsChunkSize<MicroBatchScheduler::ContextCh
SizeType32 actualChunkSize = llmReq->getContextChunkSize();
SizeType32 actualIncrement = actualChunkSize - pastChunkSize;

// Compute-aware budget: reusable tokens are served from cache and do not
// consume forward-pass capacity. Only the tokens beyond the reusable prefix count.
SizeType32 const reusable = llmReq->isFirstContextChunk()
? std::min(llmReq->getEstimatedReusableTokens(), llmReq->getContextRemainingLength())
: 0;
SizeType32 const pastCompute = std::max<SizeType32>(0, pastChunkSize - std::min(reusable, pastChunkSize));
SizeType32 const actualCompute
= std::max<SizeType32>(0, actualChunkSize - std::min(reusable, actualChunkSize));
// Compute-aware budget accounting for setPrepopulatedPromptLen's
// chunk-shift behaviour (non-last chunks keep their full size).
SizeType32 const contextRemaining = llmReq->getContextRemainingLength();
SizeType32 const reusable
= llmReq->isFirstContextChunk() ? std::min(llmReq->getEstimatedReusableTokens(), contextRemaining) : 0;
SizeType32 const pastCompute = reuse_adjusted_compute(pastChunkSize, reusable, contextRemaining);
SizeType32 const actualCompute = reuse_adjusted_compute(actualChunkSize, reusable, contextRemaining);
SizeType32 const computeIncrement = actualCompute - pastCompute;

if ((ctxTokensCapacity && numCtxTokens + computeIncrement > ctxTokensCapacity.value())
Expand Down Expand Up @@ -181,24 +198,21 @@ void MicroBatchScheduler::setCtxRequestsChunkSize<MicroBatchScheduler::ContextCh
for (auto& llmReq : contextsToBeChunked)
{
SizeType32 const suggestedChunkSize = llmReq->getContextRemainingLength();
// Reusable tokens are "free" — they don't consume forward-pass compute budget.
SizeType32 const reusable
= llmReq->isFirstContextChunk() ? std::min(llmReq->getEstimatedReusableTokens(), suggestedChunkSize) : 0;
SizeType32 const computeCost = suggestedChunkSize - reusable;
SizeType32 const computeCost = reuse_adjusted_compute(suggestedChunkSize, reusable, suggestedChunkSize);
SizeType32 actualChunkSize = suggestedChunkSize;
if (ctxTokensCapacity && computeCost > ctxTokensCapacity.value())
{
// Model processes min(chunk_size, P - reusable) tokens starting from position reusable.
// To keep model tokens within budget: chunk_size <= capacity (not reusable + capacity).
actualChunkSize = ctxTokensCapacity.value();
}
if (maxContextLength)
{
// maxContextLength limits compute tokens, not total tokens.
SizeType32 const actualCompute = std::max<SizeType32>(0, actualChunkSize - reusable);
SizeType32 const actualCompute = reuse_adjusted_compute(actualChunkSize, reusable, suggestedChunkSize);
if (actualCompute > maxContextLength.value())
{
actualChunkSize = std::min<SizeType32>(reusable + maxContextLength.value(), suggestedChunkSize);
actualChunkSize = maxContextLength.value();
actualChunkSize = std::min(actualChunkSize, suggestedChunkSize);
}
}
if (actualChunkSize != suggestedChunkSize)
Expand All @@ -208,10 +222,7 @@ void MicroBatchScheduler::setCtxRequestsChunkSize<MicroBatchScheduler::ContextCh
llmReq->setContextChunkSize(actualChunkSize);
if (ctxTokensCapacity)
{
// Decrement by actual model token count: min(chunk_size, P - reusable).
// This equals min(actualChunkSize, computeCost) since computeCost = suggestedChunkSize - reusable.
SizeType32 const modelCost
= std::min(actualChunkSize, std::max<SizeType32>(0, suggestedChunkSize - reusable));
SizeType32 const modelCost = reuse_adjusted_compute(actualChunkSize, reusable, suggestedChunkSize);
ctxTokensCapacity = ctxTokensCapacity.value() - modelCost;
}
}
Expand Down Expand Up @@ -349,10 +360,12 @@ std::tuple<RequestVector, RequestVector> MicroBatchScheduler::operator()(Request
if (!mCtxChunkConfig) // skip chunking
{
constexpr SizeType32 beam{0};
reqNumTokens
= llmReq->getNumTokens(beam) + (llmReq->hasDraftTokens() ? llmReq->getNumDraftTokens() : 0);
// Compute tokens = total - reusable (at least 1 to make progress)
SizeType32 const computeTokens = std::max(1, reqNumTokens - reusable);
SizeType32 const contextTokens = llmReq->getNumTokens(beam);
SizeType32 const draftTokens = llmReq->hasDraftTokens() ? llmReq->getNumDraftTokens() : 0;
reqNumTokens = contextTokens + draftTokens;
SizeType32 const contextCompute
= reuse_adjusted_compute(contextTokens, reusable, llmReq->getContextRemainingLength());
SizeType32 const computeTokens = std::max(1, contextCompute + draftTokens);
TLLM_CHECK_WITH_INFO(!mMaxContextLength || computeTokens <= mMaxContextLength.value(),
"Context compute tokens (%d) exceeds the limit value (%d)", computeTokens,
mMaxContextLength.value());
Expand All @@ -369,9 +382,8 @@ std::tuple<RequestVector, RequestVector> MicroBatchScheduler::operator()(Request
llmReq->setContextChunkSize(llmReq->getContextRemainingLength());
auto const draftTokens
= (llmReq->isLastContextChunk() && llmReq->hasDraftTokens()) ? llmReq->getNumDraftTokens() : 0;
// Compute cost: context compute + draft tokens
// (reusable tokens only offset context tokens, not draft tokens)
SizeType32 const contextCompute = std::max(0, llmReq->getContextChunkSize() - reusable);
SizeType32 const contextCompute = reuse_adjusted_compute(
llmReq->getContextChunkSize(), reusable, llmReq->getContextRemainingLength());
SizeType32 computeTokens = contextCompute + draftTokens;

if (mMaxContextLength)
Expand Down Expand Up @@ -444,10 +456,9 @@ std::tuple<RequestVector, RequestVector> MicroBatchScheduler::operator()(Request
if (llmReq->getContextChunkSize() > 0)
{
contextRequests.emplace_back(llmReq);
// Only count compute tokens (total - reusable).
// Reusable credit only applies to the first context chunk.
SizeType32 const reusable = llmReq->isFirstContextChunk() ? llmReq->getEstimatedReusableTokens() : 0;
SizeType32 const computeTokens = std::max(0, llmReq->getContextChunkSize() - reusable);
SizeType32 const computeTokens
= reuse_adjusted_compute(llmReq->getContextChunkSize(), reusable, llmReq->getContextRemainingLength());
batchNumTokens += computeTokens;
TLLM_LOG_DEBUG("context request scheduled: ID %lu, chunk size %d%s", llmReq->mRequestId,
llmReq->getContextChunkSize(), reusable > 0 ? (", reusable " + std::to_string(reusable)).c_str() : "");
Expand Down
86 changes: 71 additions & 15 deletions cpp/tests/unit_tests/batch_manager/microBatchSchedulerTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -953,21 +953,24 @@ TEST_F(MicroBatchSchedulerTest, ReusableTokensWithChunkedContextEqualProgress)
{
// Test compute-aware budget tracking in EQUAL_PROGRESS with reusable tokens.
//
// Setup: 2 requests, promptLen=15, reusable=10, compute budget=3, chunkUnit=1.
// Setup: 2 requests, promptLen=15, reusable=10, compute budget=12, chunkUnit=1.
//
// In EQUAL_PROGRESS, chunks grow by 1 per request per iteration. Reusable tokens
// do not consume forward-pass capacity, so the budget is only charged for tokens
// beyond the reusable prefix.
// setPrepopulatedPromptLen shifts the chunk window right by the reused amount.
// For non-last chunks (reusable + chunkSize < contextRemaining), cost = chunkSize.
// For last chunks (reusable + chunkSize >= contextRemaining), cost = contextRemaining - reusable.
//
// Expected (compute-aware EQUAL_PROGRESS):
// Tokens 0-10 are "free" (all cached). Budget is only consumed for tokens > 10.
// req0: chunk=12 (model cost = 12-10 = 2)
// req1: chunk=11 (model cost = 11-10 = 1)
// total compute = 3 = budget (fully utilised).
// With reusable=10, contextRemaining=15:
// chunkSize 1-4: non-last (10+4=14 < 15), cost = chunkSize.
// chunkSize >= 5: last (10+5=15 >= 15), cost = max(0, 15-10) = 5.
//
// Bug (raw-token EQUAL_PROGRESS): chunks capped at 2 and 1 (budget fully consumed
// after the very first two tokens, ignoring reusable credits).
constexpr SizeType32 maxNumTokens = 3;
// EQUAL_PROGRESS grows chunks in lock-step:
// Iters 1-4: both grow to chunk=4, total compute = 2*4 = 8.
// Iter 5: both reach chunk=5 (last-chunk threshold), cost=5, increment=1 each. total=10.
// Iters 6+: cost stays 5, compute increment=0 — chunks grow for free to full context.
// Result: both complete full context (chunk=15), total compute = 10 < 12.
//
// Without reusable tokens, budget=12 would only allow both to reach chunk=6 (total=12).
constexpr SizeType32 maxNumTokens = 12;
constexpr SizeType32 maxBatchSize = 4;
constexpr SizeType32 chunkUnitSize = 1;
constexpr SizeType32 reusableTokens = 10;
Expand All @@ -993,9 +996,62 @@ TEST_F(MicroBatchSchedulerTest, ReusableTokensWithChunkedContextEqualProgress)

EXPECT_EQ(ctx.size(), 2u) << "Both requests should be scheduled";

// req0 gets one extra unit over req1 due to equal-progress ordering.
EXPECT_EQ(req0->getContextChunkSize(), 12) << "req0: reusable(10) + 2 compute tokens = chunk 12";
EXPECT_EQ(req1->getContextChunkSize(), 11) << "req1: reusable(10) + 1 compute token = chunk 11";
// Both complete full context: once past the last-chunk threshold (chunkSize=5),
// additional tokens cost 0 compute, so chunks grow to full promptLen.
EXPECT_EQ(req0->getContextChunkSize(), 15) << "req0: full context (last-chunk cost capped at 5)";
EXPECT_EQ(req1->getContextChunkSize(), 15) << "req1: full context (last-chunk cost capped at 5)";
}

TEST_F(MicroBatchSchedulerTest, ReusableTokensChunkShiftNonLastChunk)
{
// Test that reuse_adjusted_compute returns chunkSize (not chunkSize - reusable)
// for non-last chunks where reusable + chunkSize < contextRemaining.
//
// setPrepopulatedPromptLen shifts the chunk window right by the reused amount
// rather than shrinking it, so non-last chunks still process ~chunkSize tokens.
//
// Setup: 1 request, promptLen=100, reusable=30, FCFS, chunkUnit=1.
// Budget = 25 (maxNumTokens).
//
// Old (wrong) formula: compute = max(0, chunkSize - reusable)
// → chunk=100, compute = max(0, 100-30) = 70 > 25 → chunked to 25,
// compute = max(0, 25-30) = 0 → budget barely touched.
//
// New (correct) formula: reuse_adjusted_compute(chunkSize=25, reusable=30, remaining=100)
// → reusable(30) + chunkSize(25) = 55 < remaining(100) → non-last chunk
// → compute = chunkSize = 25 = budget → correct accounting.
//
// With 2 requests: budget=25 should only fit one non-last chunk of 25 tokens,
// not two (which the old formula would allow by underestimating compute to 0).
constexpr SizeType32 maxNumTokens = 25;
constexpr SizeType32 maxBatchSize = 4;
constexpr SizeType32 chunkUnitSize = 1;
constexpr SizeType32 reusableTokens = 30;
constexpr SizeType32 promptLen = 100;
constexpr SizeType32 maxNewTokens = 5;
constexpr ContextChunkingPolicy ctxChunkPolicy{ContextChunkingPolicy::kFIRST_COME_FIRST_SERVED};

mNumContexts = 2;
mContextRequests.resize(mNumContexts);
mMicroBatchScheduler
= std::make_shared<MicroBatchScheduler>(ContextChunkingConfig{ctxChunkPolicy, chunkUnitSize}, std::nullopt);

RequestVector activeRequests;
auto req0 = createRequest(promptLen, maxNewTokens, 0);
auto req1 = createRequest(promptLen, maxNewTokens, 1);
req0->setEstimatedReusableTokens(reusableTokens);
req1->setEstimatedReusableTokens(reusableTokens);
activeRequests.push_back(req0);
activeRequests.push_back(req1);

ReqIdsSet inflightReqIds;
auto const [ctx, gen] = (*mMicroBatchScheduler)(activeRequests, inflightReqIds, maxBatchSize, maxNumTokens);

// req0: chunk=25, reuse_adjusted_compute(25, 30, 100) = 25 (non-last: 30+25<100)
// Budget fully consumed → req1 gets chunk=0.
EXPECT_EQ(ctx.size(), 1u) << "Only req0 fits; non-last chunk costs full chunkSize";
EXPECT_EQ(req0->getContextChunkSize(), 25) << "req0: chunk=25 (budget fully consumed by non-last chunk compute)";
EXPECT_EQ(req1->getContextChunkSize(), 0) << "req1: no budget remaining";
}

TEST_F(MicroBatchSchedulerTest, ReusableTokensZeroHasNoEffect)
Expand Down
51 changes: 46 additions & 5 deletions tensorrt_llm/_torch/pyexecutor/py_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2859,6 +2859,50 @@ def _balance_adp_requests(self, context_requests: list[LlmRequest],
balanced_context_requests = context_requests
return balanced_context_requests

@staticmethod
def _compute_scheduled_tokens(context_requests, generation_requests):
"""Compute the total number of scheduled tokens for batch waiting decisions.

For context requests, we estimate the actual compute tokens for this
iteration (excluding tokens served from KV cache).

For generation requests, each contributes 1 + num_draft_tokens.

Note on reusable token handling:
estimated_reusable_tokens is an absolute count from position 0.
Depending on the scheduler, context_current_position may or may not
have been advanced past the reusable prefix by the time this method
is called:
- V1 scheduler: prepare_context runs after scheduling, so
context_current_position is still 0.
- V2 scheduler: prepare_context runs during scheduling, so
context_current_position is already advanced to the reused offset.
To handle both correctly, the reusable credit applied to the current
chunk is max(0, reusable - context_current_position), i.e. only the
portion of the reusable range that falls within this chunk's span.
"""
num_scheduled_ctx_tokens = 0
for ctx_req in context_requests:
reusable = (ctx_req.estimated_reusable_tokens
if ctx_req.is_first_context_chunk else 0)
# Credit only the reusable tokens that overlap with the current
# chunk: if context_current_position has already been advanced past
# the reusable prefix (V2), the credit is 0; if not (V1), the full
# reusable count is subtracted.
reusable_in_chunk = max(0,
reusable - ctx_req.context_current_position)
remaining = ctx_req.context_remaining_length
if reusable_in_chunk <= 0:
compute = ctx_req.context_chunk_size
elif reusable_in_chunk + ctx_req.context_chunk_size < remaining:
compute = ctx_req.context_chunk_size
else:
compute = max(1, remaining - reusable_in_chunk)
num_scheduled_ctx_tokens += compute
num_scheduled_gen_tokens = sum(1 + gen_req.num_draft_tokens
for gen_req in generation_requests)
return num_scheduled_ctx_tokens + num_scheduled_gen_tokens

def _waiting_requests(self, context_requests: list[LlmRequest],
generation_requests: list[LlmRequest]):
"""
Expand All @@ -2868,11 +2912,8 @@ def _waiting_requests(self, context_requests: list[LlmRequest],
- The number of waiting iterations is smaller than `self.batch_wait_timeout_iters`.
"""

num_scheduled_ctx_tokens = sum(
len(ctx_req.get_tokens(0)) for ctx_req in context_requests)
num_scheduled_gen_tokens = sum(1 + gen_req.num_draft_tokens
for gen_req in generation_requests)
num_scheduled_tokens = num_scheduled_ctx_tokens + num_scheduled_gen_tokens
num_scheduled_tokens = self._compute_scheduled_tokens(
context_requests, generation_requests)

should_waiting = self.batch_wait_iters_count < self.batch_wait_timeout_iters and num_scheduled_tokens < self.batch_wait_max_tokens_ratio * self.max_num_tokens
if should_waiting:
Expand Down
Loading
Loading