Skip to content

Host the Qwen3.8-Flash CUDA-graph decode loop in the Engine's static path - #2728

Open
Tianlei Wu (tianleiwu) wants to merge 9 commits into
tlwu/20261013/ga-b3-qwen38-graph-modelfrom
tlwu/20261013/ga-b4-qwen38-engine-host
Open

Tianlei Wu (tianleiwu) wants to merge 9 commits into
tlwu/20261013/ga-b3-qwen38-graph-modelfrom
tlwu/20261013/ga-b4-qwen38-engine-host

Conversation

@tianleiwu

@tianleiwu Tianlei Wu (tianleiwu) commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #2727 (base tlwu/20261013/ga-b3-qwen38-graph-model; the diff shows only this change). Position 3 of the series described in #2726. Retarget to main once #2727 merges.

Description

Serves the qwen38_flash_graph model type (#2727) through the Engine's existing static path. The CUDA-graph decode loop (the MTP draft -> verify -> commit cycle, the folded cycle, the dense fast prefill and the shared weight pool) stays on the device; the Engine keeps ownership of requests, turns, cancellation, stop conditions and speculative statistics. It is a single-slot backend: one resident request, greedy decoding, no prefix cache; further requests queue behind it in the static batch scheduler. Multi-slot hosting comes with ragged batching later in the series.

Multi-draft cycles of 2 to 4 tokens (S=2..4) are reported through Qwen38StepResult::tokens and the speculative statistics. The prompt length of a turn is handed to the device with Qwen38DecodeDevice::ExpectPrefillTokens (after the device Reset(), before the first prompt chunk), so QMoE memory lending can size the prefill budget from state that belongs to that device; the executor itself neither reads nor writes the process environment (ORT_QMOE_PREFILL_TOTAL_TOKENS is published by the device only around a single run). The prompt chunk size comes from EffectiveQwen38PrefillChunkTokens of the reworked qwen38_flash_graph configuration (#2727).

Summary of Changes

File Change
src/engine/qwen38_graph_executor.{h,cpp} Qwen38GraphCacheManager (static-cache contract for one row), Qwen38GraphExecutor (a ModelExecutor that selects its own tokens: one Engine step is one prompt chunk of the size the device asks for, or one whole decode cycle) and Qwen38GraphSession (platform- and CUDA-independent logic between the executor and a Qwen38DecodeDevice; checks the device error bits when a turn ends; continuation and rewind re-ingest the history through the same chunked prefill; the speculative statistics count the drafts that were actually evaluated and classify rounds by the drafts they delivered, as Engine::RecordSpeculativeCommit does).
src/engine/engine.cpp, model_executor.h, cache_manager.h Hooks, all defaulting to the previous behaviour for other models: turn validation before any Request mutation (sampling, repetition penalty, no-repeat n-grams, minimum token counts, seeds, guidance and oversized sessions are rejected with a clear error), speculative-statistics reporting, static batch and length limits, and CreateDependencies returning early for this model type before any IndexShare/MTP setup.
src/engine/request.{h,cpp}, scheduled_requests.{h,cpp} Request::CommitExternalTokens applies EOS, length limits and stop strings per committed token (precedence: stop string, EOS, turn limit, context limit; later tokens of a cycle are dropped); ScheduledRequests stages the executor's tokens. Stop strings are accepted on this static path only.
docs/paged_attention_engine.md New section on the static single-slot backend that selects its own tokens: chunking, ExpectPrefillTokens, the Drain() + CheckDecodeStatus() contract and the speculative statistics.
test/cpp/engine/qwen38_graph_executor_tests.cpp, qwen38_graph_test_doubles.h 59 tests (plus one disabled benchmark) against a scripted Qwen38DecodeDevice and the real Engine: dense chunk streaming (any size, shorter than or exactly the chunk, hand-off exactly once and only after the last chunk), hand-off skipped when the first token ends the turn, cancellation between chunks and during decode, stop strings, EOS, continuation and rewind, all-rejected and all-accepted cycles, device errors (including failures that only show after Drain()), capability errors, evaluated-draft and round counts of truncated rounds, and the expected prefill total of two Engines on two owner threads. The scripted device completes cycles asynchronously like the real one (Drain() leaves the record in flight, CheckDecodeStatus() reports the accumulated status bits, Reset() clears them).

The dynamic/paged Engine path and the MTP path added by #2714 are not changed: the new Request/ScheduledRequests entry points are only reached from this executor, which never attaches drafts (MaxDraftTokensPerStep() == 0).

Review feedback incorporated

  • Qwen38GraphSession::Release() and Begin() propagate an unchecked device decode status error (and capture a deferred teardown error), so a turn that ends through a stop string, cancellation, rewind or replacement still checks the device validation bits before Reset().
  • The committed RequestStepResult is fed back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens, so tokens_emitted, tokens_discarded and the accepted and bonus counts reflect the tokens that were actually committed. Tests cover EOS, stop-string and canceled turns.
  • A device failure that only becomes visible after Drain() (the look-ahead cycle that is completed when a turn is canceled, ends on a stop string or is rewound) fails the request instead of being cleared by the next Reset(). Every such path runs Drain() and then CheckDecodeStatus(), which on the reworked device contract (Add the qwen38_flash_graph model type and its device decode engine #2727) reads the status bits accumulated since the last Reset() even when a finished cycle record is left unconsumed, so no executor change was needed; the scripted device now behaves like the real one and AFailureOfTheLookAheadCycleVisibleOnlyAfterDrain* / AFailureOfTheFirstCycleVisibleOnlyAfterDrainSurfacesAfterACancellation cover it.
  • The evaluated-draft count no longer over-counts a truncated round: it is the accepted-prefix positions the turn consumed plus the rejected position only when the round reached it (three accepted drafts with room for one token: 1 evaluated and 1 accepted, not 2 evaluated). Turn and session limits, stop strings and EOS never add a position beyond the boundary.
  • Rounds are classified as zero, partial or full accept by the number of drafts the turn took from the round, partial_accept_rounds is counted for every partial round, and AddStats merges it.
  • The expected prefill total is state of the device (ExpectPrefillTokens), not of the process: the executor no longer calls setenv. EnginesOnTwoOwnerThreadsKeepTheirOwnExpectedPrefillTotals runs two Engines on two owner threads with their own scripted devices and checks that each device sees its own total in every chunk of a turn (first turn, continued turn, canceled turn, following request) and that ORT_QMOE_PREFILL_TOTAL_TOKENS is untouched.

Testing

  • CUDA build (-DUSE_CUDA=ON -DENABLE_QWEN38_PLANE=ON, CUDA 13.0, H200/sm_90) on top of the reworked Add the qwen38_flash_graph model type and its device decode engine #2727 (85595784): engine_unit_tests --gtest_filter='*Qwen38*' 135 passed, 0 failed. The full binary runs 1249 tests: 1209 passed, 22 skipped, 18 failed, and the 18 are exactly the failures of the unmodified base in this environment (the Cuda* provider-bridge tests need GetProvider from the in-tree CUDA EP, which the rc5 plugin EP used here does not export; the three ModelPackage.* tests (TokenizerResolvesThroughSharedAsset, ExternalInitializersFolderResolvesThroughSharedAsset, FlatDirectoryRejectsSha256Reference) fail identically on unmodified main and are unrelated to this change).
  • The sources of this PR compile as C++17 with -DUSE_CXX17=1 (the Android configuration) and as C++20 with -Wall -Wextra without diagnostics.
  • GPU qualification of this code through og.Engine (original head of this PR, H200, v12 package): token identity against the reference runner 6/6 (EOS mode) and 6/6 (fixed length) with equal cycle and accept counts, MTP stream equal to the no-MTP stream 6/6, and engine_behaviours.py 27/27 in the bit-exact (--prefill steps) and the fast-prefill modes. The tip of the whole stack was re-qualified end to end on the rc5 package (see the final PR of the runtime series, [Qwen38] Adopt the SpeculativePrefixCommit contract of ORT #33474 and fix the conv-state replay orientation #2824).
  • Not run: Windows/macOS, multi-GPU, sampling (rejected by design).

Motivation and Context

The Generator-hosted version of this loop could not share the Engine's request lifecycle (turns, cancellation, stop strings, statistics, queueing), which is what the serving stack uses. Hosting it as a static-path executor reuses all of that while keeping the single-slot device state (fixed in-place KV, recurrent-state bank ring) that cannot be paged. It is an alternative backend next to the paged Engine MTP path of #2714, not a replacement.

Limits of this PR: greedy only; one resident request; no prefix cache and no caller drafts; a continued turn or the replay after RewindToStartOfTurn re-ingests the whole history (0.3 s at 2k, 3.2 s at 32k with the fast prefill); sessions up to kv_capacity - 4 tokens; Linux + CUDA only. The GPU gates need an exported package, the CUDA EP and the contrib operators, which CI does not have, so CI covers the scripted-device tests only.

Checklist

  • Tests added/updated
  • No breaking changes (hooks default to the previous behaviour)
  • Documentation updated

Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 14, 2026
…th auxiliary sessions (#2725)

## Description

Adds `session_options.custom_ops_libraries`, a list of custom-op
libraries registered in order after the existing single
`custom_ops_library`, and exports `InheritSessionOptionsFromParent` so
components that create auxiliary sessions (for example MTP/draft heads)
can inherit the decoder's providers, provider options, config entries
and custom ops.

Models whose graphs use several custom-op domains (fused attention,
hyper-connection, GEMM and head ops shipped next to the model package)
currently cannot name more than one library. This change is generic and
has no dependency on any model type; it is the first step of the
Qwen3.8-Flash static-graph backend series (see the end of this
description), but stands alone and targets `main`.

## Summary of Changes

| File | Change |
|------|--------|
| `src/config.h`, `src/config.cpp` | New
`session_options.custom_ops_libraries` (JSON string array; scalar values
such as `"custom_ops_libraries": "ops.so"` are rejected during parsing).
Overlays append to `custom_ops_libraries` (and `[]` leaves existing
entries unchanged). `InheritSessionOptionsFromParent` exposes the
existing inheritance helper; `custom_ops_library` and
`custom_ops_libraries` are inherited together when the child sets
neither (note that `"custom_ops_libraries": []` leaves the vector empty
and therefore still inherits from the parent unless `custom_ops_library`
is set). |
| `src/models/model.cpp` | `custom_ops_library` and then every
`custom_ops_libraries` entry go through the same path-traversal and
allow-list checks, with duplicate resolved paths registered once. All
paths are validated against the allow-list before loading starts;
validated libraries are then loaded and registered sequentially in order
(so a later load/registration error can still leave earlier libraries
registered on `session_options`). |
| `test/CMakeLists.txt`,
`test/cpp/engine/custom_ops_test_lib/custom_ops_test_lib.cpp`,
`test/cpp/engine/custom_ops_libraries_tests.cpp` | Builds two small test
custom-op shared libraries (`custom_ops_test_first` and
`custom_ops_test_second`) and tests parsing/ordering, overlay rules,
rejection of non-string entries and scalar values, inheritance
(including `[]` and `CreateMtpDecoderConfig`), allow-list validation,
and actual library loading, ordering, and deduplication. |

### Library Location Requirement

Every entry in `custom_ops_library` and `custom_ops_libraries` must
resolve to an existing file inside one of the allowed directories:
1. The model folder (alongside `genai_config.json`),
2. A registered Execution Provider (EP) library directory, or
3. The current working directory.

Previously, an unresolved bare library name in `custom_ops_library` was
passed directly to `RegisterCustomOpsLibrary` and could be resolved
through the platform loader search path (`LD_LIBRARY_PATH` / `PATH`).
Unresolved entries are now rejected before loading so the directory
allow-list cannot be bypassed; users should place custom-op libraries in
one of the allowed directories above (typically the model folder).

## Testing

- CPU-only Release build (`-DUSE_CUDA=OFF`) on `main`:
`engine_unit_tests` **929 passed, 0 failed, 2 skipped** (including all 9
tests in `CustomOpsLibrariesConfigTest`, which load real test custom-op
shared libraries to verify registration, ordering, and deduplication).
- `lintrunner`, `clang-format` (20.1.8) and `git diff --check` are
clean.

## Motivation and Context

Found while hosting a model that uses seven custom-op libraries.
Registering them through one `custom_ops_library` entry was not
possible, and auxiliary sessions did not inherit the decoder's custom
ops, so head graphs failed to load without repeating the whole session
configuration.

This is the first PR of a stack that adds an Engine-hosted CUDA-graph
MTP backend for Qwen3.8-Flash-Next (greedy only, one resident request,
Linux/CUDA, no prefix cache). The follow-ups are drafts stacked on
`kvaishnavi/qwen-38-flash-run`, so this commit is also carried as the
first commit of the next PR (#2726) until this one merges. The stack:
#2726 (CUDA data plane library) -> #2727 (model type and device engine)
-> #2728 (Engine hosting) -> #2729 (qualification tools).

## Checklist

- [x] Tests added/updated
- [x] No breaking changes
- [ ] Documentation updated (the option is described in the PR; no
dedicated config documentation exists for `session_options` yet)

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 7463063 to 9a9a946 Compare October 15, 2026 04:48
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch 2 times, most recently from be1c6f9 to 4719a3f Compare October 19, 2026 08:57
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 30, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 4719a3f to 17b0299 Compare October 30, 2026 05:12
@tianleiwu

Tianlei Wu (tianleiwu) commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto the new head of #2727 and force-pushed (4719a3f05 -> e864af904). The description was rewritten to match the re-cut series; the series map is in #2726. The head includes a whitespace-only clang-format pass to match the CI's clang-format 20. Re-based again onto the amended #2726 (Windows rooted-path fix); the change of this PR is the same.

Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 30, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 17b0299 to 083043a Compare October 30, 2026 05:44
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 30, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 083043a to e864af9 Compare October 30, 2026 06:24
@tianleiwu
Tianlei Wu (tianleiwu) marked this pull request as ready for review October 30, 2026 18:34
Copilot AI balanced review requested due to automatic review settings October 30, 2026 18:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Pending look-ahead cycles can silently discard device errors after a stop string or cancellation, while multi-draft acceptance statistics also remain incorrect.

3 open issues
What changed in this PR

Hosts the qwen38_flash_graph device backend introduced in #2727 through the Engine’s static path, integrating GPU decode cycles with request lifecycle management.

Changes:

  • Adds a single-slot executor and cache manager with chunked prefill, continuation, and rewind support.
  • Adds token-wise commit handling for stop conditions and hooks for capability validation and speculative statistics.
  • Documents the backend and adds scripted-device tests for lifecycle, decoding, and error handling.
File Description
test/​cpp/​engine/​qwen38_graph_test_doubles.h Adds a scripted decode device for Engine tests.
test/​cpp/​engine/​qwen38_graph_executor_tests.cpp Tests decoding, lifecycle, limits, and device failures.
src/​engine/​scheduled_requests.h Declares executor-token staging.
src/​engine/​scheduled_requests.cpp Routes staged tokens through request commit handling.
src/​engine/​request.h Declares external-token commit support.
src/​engine/​request.cpp Applies stop conditions to executor-selected tokens.
src/​engine/​qwen38_graph_executor.h Defines the session, executor, and single-slot cache manager.
src/​engine/​qwen38_graph_executor.cpp Implements device orchestration and speculative accounting.
src/​engine/​model_executor.h Adds token-selection, validation, and statistics hooks.
src/​engine/​engine.cpp Integrates backend creation, commit notifications, and capabilities.
src/​engine/​cache_manager.h Adds static batch and session-length capability hooks.
docs/​paged_attention_engine.md Documents the backend’s behavior and restrictions.

Review effort: Balanced

Comment thread src/engine/qwen38_graph_executor.cpp Resolved
Comment thread src/engine/qwen38_graph_executor.cpp Resolved
Comment thread src/engine/qwen38_graph_executor.cpp Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed all 12 changed files at e864af9 plus the production device, request, cache and stats contracts. One new cross-Engine environment-state defect is recorded inline. The three existing unresolved threads on deferred status, evaluated-draft counts after truncation, and partial-accept accounting remain valid; I did not create duplicate findings. The scripted device's Drain behavior should not be taken as evidence that the production status path is fixed. No repository build, GPU execution or actual concurrent-Engine test was performed; this is source/control-flow review, not approval.

Comment thread src/engine/qwen38_graph_executor.cpp Resolved
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 31, 2026
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 31, 2026
The wasted look-ahead cycle of a stop-string or canceled turn is never
waited for, so its record stays unconsumed and only the device status bits
can tell that it failed. The production device keeps that record after
Drain() and, since #2727, CheckDecodeStatus() reads the accumulated bits
also then; the Reset() that follows clears them.

The executor already drains and checks the status before every Reset()
(Begin, Release, end of turn). The scripted device now behaves like the
production one so that this is actually exercised: Drain() completes the
running cycle but leaves its record in flight, the cycle can accumulate
error bits (fail_cycle), CheckDecodeStatus() reads them with a cycle in
flight or unconsumed, and the device logs the drain/status/reset order.

New tests: a failing look-ahead cycle of a stop-string turn surfaces when
the turn is rewound and fails the next request instead of being reset
away; the same for a canceled turn; a clean drained cycle lets the next
request run. Document the Drain() + CheckDecodeStatus() contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from e864af9 to 5c70b96 Compare October 31, 2026 02:16
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 31, 2026
A cycle that the Engine ends inside its accepted prefix (turn or session
limit, stop string, EOS) was reported with min(proposed, accepted + 1)
evaluated drafts. With three accepted drafts but room for one token that
is two evaluated drafts for one accepted one, a 50% acceptance rate where
Engine::RecordSpeculativeCommit reports 100%.

Count the draft positions the turn examined up to its terminal token, like
Request::CommitAcceptedDraftsForTransaction does: every accepted draft it
consumed (an EOS draft is examined but not accepted) plus the rejected
position only when the turn reached the correction token.

The scripted device gains multi-draft cycles (SetDrafts: 1..3 drafts per
verify, any accepted prefix, reported through accepted_count/proposed_count
like the production device). Tests cover limit, session limit, stop string
and EOS truncation inside the accepted prefix, on the correction token and
on the bonus token; five of them fail with the old formula.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 31, 2026
A round that accepted some but not all of its drafts only recorded its
histogram entry: the zero/full classification skipped it and
partial_accept_rounds was never incremented, nor merged by AddStats.

Classify every round like Engine::RecordSpeculativeCommit does, by the
number of accepted drafts that were delivered (none: zero, all of the
proposed ones: full, otherwise partial), independently of whether a
correction token followed, and merge partial_accept_rounds in AddStats.
Bonus and correction tokens keep being counted on their own.

Tests cover a 0/1/2/3 accepted sequence of three-draft rounds (counters,
histogram, correction/bonus, acceptance rate), a partial round whose
correction does not fit the turn, a round truncated inside its accepted
drafts, an EOS first draft (zero accept) and the sum over requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Oct 31, 2026
…l total

The executor hands the prompt length of a turn to its own device
(ExpectPrefillTokens, after Reset() and before the first chunk) and never
publishes it through the process environment. Pin that with a scripted-device
test: two Engines on two owner threads, each with its own model and device, are
held at a rendezvous between the publication of the length and the first chunk
of every turn, and each device must keep its own total across the chunks of a
new turn, a continued turn that re-ingests the history after a Reset, and a
canceled turn, while ORT_QMOE_PREFILL_TOTAL_TOKENS stays untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…path

Serves `qwen38_flash_graph` through the existing Engine seams so requests,
turns, cancellation, stop conditions and speculative statistics stay owned by
the Engine. The backend is a single-slot static path: one resident request,
greedy decoding, no prefix cache; further requests queue behind it in the
static batch scheduler.

- `Qwen38GraphCacheManager`: static-cache contract for one row.
- `Qwen38GraphExecutor`: a `ModelExecutor` that selects its own tokens. One Engine
  step is one prompt chunk (the device's dense prefill chunk size) or one whole
  decode cycle (the folded draft/verify/commit cycle, whose one or two committed
  tokens are published through `Request::CommitExternalTokens`). Device error bits
  are checked when a turn ends; a continued or rewound turn re-ingests its history
  through the same chunked prefill.
- `Qwen38GraphSession`: the platform- and CUDA-independent logic between the
  executor and a `Qwen38DecodeDevice`.
- Engine hooks, all defaulting to the previous behaviour for other models: turn
  validation with capability errors (sampling, repetition penalty, minimum token
  counts, seeds and oversized sessions are rejected up front), static-path stop
  strings and limits, speculative-statistics reporting, and
  `Engine::CreateDependencies` returning early for this model type before any
  IndexShare or MTP setup.
- `Request::CommitExternalTokens` applies EOS, length limits and stop strings per
  committed token.
- Tests (scripted device, no GPU): 34 executor/session cases covering dense chunk
  streaming and hand-off, cancel during prefill and decode, stop strings, EOS
  handling, continuation and rewind, all-rejected and all-accepted cycles, device
  errors, and capability errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…culative stats

- Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset().

- Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens.

- Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n Qwen38GraphSession

- Extend Qwen38StepResult::tokens to 4 tokens and update PendingStep/OnStepCommitted speculative stats for multi-draft S=2..4 cycles while preserving committed-token clipping.
- Export ORT_QMOE_PREFILL_TOTAL_TOKENS at the start of prompt prefill so QMoE memory lending can size the prefill budget.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…evice API

The graph model now sizes its prompt chunks from model.qwen38_graph.prefill.chunk
(model.qwen38_graph.prefill_chunk_tokens is removed) and receives the expected
prompt length through the device instead of the process environment.

- CreateQwen38GraphDependencies validates the chunk configuration with
  ValidateQwen38PrefillChunk and sizes the session from
  EffectiveQwen38PrefillChunkTokens; a device that names its own chunk still
  overrides it.
- Qwen38GraphSession no longer writes ORT_QMOE_PREFILL_TOTAL_TOKENS: it calls
  Qwen38DecodeDevice::ExpectPrefillTokens(total) after Reset() and before the
  first PrefillChunk() of every turn.
- The scripted device implements ExpectPrefillTokens (device-local, dropped by
  Reset()/BeginDecode()) and records the total every chunk saw; the tests use
  prefill.chunk, cover the default and invalid chunk configurations, the session's
  even rounding, and the announcement order.
- docs/paged_attention_engine.md describes the chunk sizing and the
  announcement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The wasted look-ahead cycle of a stop-string or canceled turn is never
waited for, so its record stays unconsumed and only the device status bits
can tell that it failed. The production device keeps that record after
Drain() and, since #2727, CheckDecodeStatus() reads the accumulated bits
also then; the Reset() that follows clears them.

The executor already drains and checks the status before every Reset()
(Begin, Release, end of turn). The scripted device now behaves like the
production one so that this is actually exercised: Drain() completes the
running cycle but leaves its record in flight, the cycle can accumulate
error bits (fail_cycle), CheckDecodeStatus() reads them with a cycle in
flight or unconsumed, and the device logs the drain/status/reset order.

New tests: a failing look-ahead cycle of a stop-string turn surfaces when
the turn is rewound and fails the next request instead of being reset
away; the same for a canceled turn; a clean drained cycle lets the next
request run. Document the Drain() + CheckDecodeStatus() contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A cycle that the Engine ends inside its accepted prefix (turn or session
limit, stop string, EOS) was reported with min(proposed, accepted + 1)
evaluated drafts. With three accepted drafts but room for one token that
is two evaluated drafts for one accepted one, a 50% acceptance rate where
Engine::RecordSpeculativeCommit reports 100%.

Count the draft positions the turn examined up to its terminal token, like
Request::CommitAcceptedDraftsForTransaction does: every accepted draft it
consumed (an EOS draft is examined but not accepted) plus the rejected
position only when the turn reached the correction token.

The scripted device gains multi-draft cycles (SetDrafts: 1..3 drafts per
verify, any accepted prefix, reported through accepted_count/proposed_count
like the production device). Tests cover limit, session limit, stop string
and EOS truncation inside the accepted prefix, on the correction token and
on the bonus token; five of them fail with the old formula.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A round that accepted some but not all of its drafts only recorded its
histogram entry: the zero/full classification skipped it and
partial_accept_rounds was never incremented, nor merged by AddStats.

Classify every round like Engine::RecordSpeculativeCommit does, by the
number of accepted drafts that were delivered (none: zero, all of the
proposed ones: full, otherwise partial), independently of whether a
correction token followed, and merge partial_accept_rounds in AddStats.
Bonus and correction tokens keep being counted on their own.

Tests cover a 0/1/2/3 accepted sequence of three-draft rounds (counters,
histogram, correction/bonus, acceptance rate), a partial round whose
correction does not fit the turn, a round truncated inside its accepted
drafts, an EOS first draft (zero accept) and the sum over requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…l total

The executor hands the prompt length of a turn to its own device
(ExpectPrefillTokens, after Reset() and before the first chunk) and never
publishes it through the process environment. Pin that with a scripted-device
test: two Engines on two owner threads, each with its own model and device, are
held at a rendezvous between the publication of the length and the first chunk
of every turn, and each device must keep its own total across the chunks of a
new turn, a continued turn that re-ingests the history after a Reset, and a
canceled turn, while ORT_QMOE_PREFILL_TOTAL_TOKENS stays untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ot hosting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tianleiwu
Tianlei Wu (tianleiwu) force-pushed the tlwu/20261013/ga-b4-qwen38-engine-host branch from 5c70b96 to 6b248b7 Compare October 31, 2026 09:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants