Skip to content

Add GPU-based constrained sampling for pipelined engine - #114

Open
stikves wants to merge 4 commits into
apple:mainfrom
stikves:feature/gpu-constrained-sampling
Open

Add GPU-based constrained sampling for pipelined engine#114
stikves wants to merge 4 commits into
apple:mainfrom
stikves:feature/gpu-constrained-sampling

Conversation

@stikves

@stikves stikves commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • GPU-accelerated grammar-constrained generation for the pipelined engine
  • Applies xgrammar bitmasks directly in the MPSGraph sampler, eliminating per-token logit transfer to CPU
  • ConstrainedGenerationCapable protocol for capability-based routing
  • Session cache with checkout/checkin pattern for xgrammar reuse across calls

Performance (Qwen3-4B, relative to unconstrained pipelined baseline)

Configuration Relative Notes
Pipelined unconstrained 100% roofline
Sequential constrained 75% existing option
Pipelined constrained + jump-forward 82% new option, tested with long string responses 

Changes

  • ConstrainedGenerationCapable — Protocol for engines that support GPU-side bitmask application
  • ConstrainedSessionHandle — Encapsulated handle with narrow API, stores tokenizer for JF
  • PipelinedConstrainedDecodingStrategy — AsyncSequence-based strategy with single-use guard, early-stop on consumer drop
  • MPSGraphSamplers — Bitmask expansion graph, applyBitmask on encode paths
  • ConstrainedGenerationSessionfillBitmask(into:) with BitmaskResult enum, rollback, findJumpForwardString
  • C bridge extensionsrollback, findJumpForwardString, isCompleted
  • llm-runner — Routes to pipelined strategy, performance metrics for constrained path
  • Integration tests — Mock engine exercising cache reuse, schema invalidation, stop sequences, error paths

Test plan

  • Existing unit tests pass (sampler, session, pipeline gate)
  • New GPU sampler tests: bitmask blocks dominant, all-ones matches unconstrained, only-allowed tokens
  • Integration tests via MockConstrainedEngine (no Metal required)
  • swift-format clean
  • End-to-end on-device validation (greedy outputs match sequential path)

@stikves
stikves force-pushed the feature/gpu-constrained-sampling branch 2 times, most recently from 0e2c429 to d4edbfb Compare July 23, 2026 21:10
@stikves
stikves marked this pull request as ready for review July 23, 2026 21:18
@stikves
stikves force-pushed the feature/gpu-constrained-sampling branch 4 times, most recently from 905f81f to cdef085 Compare July 24, 2026 17:47
@stikves
stikves force-pushed the feature/gpu-constrained-sampling branch 5 times, most recently from 43f429e to 1dbb338 Compare July 24, 2026 18:17
@stikves stikves self-assigned this Jul 24, 2026
@stikves
stikves force-pushed the feature/gpu-constrained-sampling branch 3 times, most recently from fe2a707 to 3c6b90d Compare July 24, 2026 18:41
@stikves
stikves marked this pull request as draft July 24, 2026 18:51
@stikves
stikves force-pushed the feature/gpu-constrained-sampling branch 4 times, most recently from f8cdbd8 to 0b42177 Compare July 25, 2026 00:20

@carinapeng carinapeng 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.

Thanks for looking into this. Getting this working is tricky because constrained decoding has some data dependency that vanilla decoding doesn't. From the grammar side, the accepted token to fill bit masking runs on CPU and the bit mask for token n+1 would depend on the sampled value of token n so this GPU-CPU roundtrip might need more design change. Happy to chat more on this and this work would probably benefit from breaking this into smaller pieces!

@stikves

stikves commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for looking into this. Getting this working is tricky because constrained decoding has some data dependency that vanilla decoding doesn't. From the grammar side, the accepted token to fill bit masking runs on CPU and the bit mask for token n+1 would depend on the sampled value of token n so this GPU-CPU roundtrip might need more design change. Happy to chat more on this and this work would probably benefit from breaking this into smaller pieces!

Masking happens on the GPU sampler:
https://github.com/apple/coreai-models/pull/114/changes#diff-26ae392b0ec2c8ff20901ac369417462a4f015174091b838203b599c9992db7dR239

Yes, there is a CPU work for the xgrammar, however unlike the sequential engine, it does not "stop world" to generate masks. The bitmask buffer is filled on CPU by xgrammar (which does need the prior token), hence the unavoidable synchronization. Then the sampler executable directly uses this buffer.

This is explained in the PR description, along with the relative performance metrics.

@stikves
stikves marked this pull request as ready for review July 28, 2026 16:00
Comment thread swift/Sources/CoreAILanguageModels/InferenceEngines/CoreAIPipelinedEngine.swift Outdated
Comment thread swift/Sources/CoreAILanguageModels/InferenceEngines/CoreAIPipelinedEngine.swift Outdated
Comment thread swift/Sources/CoreAILanguageModels/InferenceEngines/CoreAIPipelinedEngine.swift Outdated
@carinapeng

carinapeng commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

This 2k line change is hard to review with confidence. A proposal is to land the GPU-masking capability and tests first, then the jump-forward + session cache as a follow-up

@carinapeng

Copy link
Copy Markdown
Contributor

Masking happens on the GPU sampler:
https://github.com/apple/coreai-models/pull/114/changes#diff-26ae392b0ec2c8ff20901ac369417462a4f015174091b838203b599c9992db7dR239
Yes, there is a CPU work for the xgrammar, however unlike the sequential engine, it does not "stop world" to generate masks. The bitmask buffer is filled on CPU by xgrammar (which does need the prior token), hence the unavoidable synchronization. Then the sampler executable directly uses this buffer.

Thanks for looking into this. Getting this working is tricky because constrained decoding has some data dependency that vanilla decoding doesn't. From the grammar side, the accepted token to fill bit masking runs on CPU and the bit mask for token n+1 would depend on the sampled value of token n so this GPU-CPU roundtrip might need more design change. Happy to chat more on this and this work would probably benefit from breaking this into smaller pieces!

Masking happens on the GPU sampler: https://github.com/apple/coreai-models/pull/114/changes#diff-26ae392b0ec2c8ff20901ac369417462a4f015174091b838203b599c9992db7dR239

Yes, there is a CPU work for the xgrammar, however unlike the sequential engine, it does not "stop world" to generate masks. The bitmask buffer is filled on CPU by xgrammar (which does need the prior token), hence the unavoidable synchronization. Then the sampler executable directly uses this buffer.

This is explained in the PR description, along with the relative performance metrics.

Thanks — agreed the masking itself is on-GPU and correct, and the "no stop-the-world" framing is right: we avoid the full logit readback + CPU softmax the sequential engine pays -- this can be a PR in itself to land first

To make sure we're on the same page on the pipelining, though.. There's still a per-token sync point — the sampled token n has to be read back to CPU so acceptToken(n) can run before fillBitmask(n+1). That's the "unavoidable synchronization" you mention, and this might be why the sampled positions can't overlap. So the real speedups here are (1) GPU masking and (2) jump-forward batching the deterministic runs — not pipelining of the constrained steps

Proposal to move forward:

  1. Split the PR. Land the well-contained piece first — ConstrainedGenerationCapable + GPU bitmask sampler + its unit tests. Then jump-forward + session-cache checkout/checkin as a follow-up

  2. Get the real loop under test. Today the integration tests use a mock that reimplements generateConstrained, so runConstrainedCompletion + jump-forward + rollback have no automated coverage — only the manual greedy on-device check. I'd like at least one automated test that drives the actual loop through a jump-forward + rollback case before we lock the protocol shape.

  3. Fix or drop stopTokenIds. It's threaded through the session inits but never reaches xgrammar (ConstrainedGenerationSession.swift:64), and the docstring claims it blocks tokens mid-generation. Either wire it in or remove it + fix the docs

@stikves

stikves commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

This 2k line change is hard to review with confidence. A proposal is to land the GPU-masking capability and tests first, then the jump-forward + session cache as a follow-up

Sure, I'll send individual PRs and rebase this as they get merged. This will stay as the last piece where everything is eventually wired.

stikves added a commit to stikves/coreai-models that referenced this pull request Jul 31, 2026
…direct bitmask fill

- rollback(_:) — unwind grammar state by N tokens (budget: 64)
- findJumpForwardString() — peek at deterministic continuations
- fillBitmask(into:) → BitmaskResult — write bitmask directly into
  a caller-provided pointer (e.g. GPU-visible MTLBuffer), avoiding
  array allocation per token
- BitmaskResult enum: .terminated, .unconstrained, .constrained
- C bridge additions: XGrammarRollback, XGrammarFindJumpForwardString,
  XGrammarFillNextTokenBitmask

Part 1 of 4 for GPU-based constrained sampling (apple#114).
stikves added a commit to stikves/coreai-models that referenced this pull request Jul 31, 2026
Both MPSGraphArgmaxSampler and MPSGraphCompositeSampler now accept an
optional bitmask MTLBuffer. When provided, the graph applies the mask
before sampling — tokens with bit=0 are suppressed by adding -inf to
their logits.

The bitmask is a packed Int32 array: bit (tokenID % 32) of word
(tokenID / 32). This matches xgrammar's output format so the mask
can be filled directly by ConstrainedGenerationSession.fillBitmask().

Part 2 of 4 for GPU-based constrained sampling (apple#114).
stikves added a commit to stikves/coreai-models that referenced this pull request Jul 31, 2026
…direct bitmask fill

- rollback(_:) — unwind grammar state by N tokens (budget: 64)
- findJumpForwardString() — peek at deterministic continuations
- fillBitmask(into:) → BitmaskResult — write bitmask directly into
  a caller-provided pointer (e.g. GPU-visible MTLBuffer), avoiding
  array allocation per token
- BitmaskResult enum: .terminated, .unconstrained, .constrained
- C bridge additions: XGrammarRollback, XGrammarFindJumpForwardString,
  XGrammarFillNextTokenBitmask

Part 1 of 4 for GPU-based constrained sampling (apple#114).
stikves added a commit that referenced this pull request Jul 31, 2026
…direct bitmask fill (#131)

* Extend ConstrainedGenerationSession with rollback, jump-forward, and direct bitmask fill

- rollback(_:) — unwind grammar state by N tokens (budget: 64)
- findJumpForwardString() — peek at deterministic continuations
- fillBitmask(into:) → BitmaskResult — write bitmask directly into
  a caller-provided pointer (e.g. GPU-visible MTLBuffer), avoiding
  array allocation per token
- BitmaskResult enum: .terminated, .unconstrained, .constrained
- C bridge additions: XGrammarRollback, XGrammarFindJumpForwardString,
  XGrammarFillNextTokenBitmask

Part 1 of 4 for GPU-based constrained sampling (#114).

* Add tests for rollback and findJumpForwardString (carinapeng)
stikves added a commit that referenced this pull request Aug 4, 2026
* Add GPU bitmask expansion for constrained sampling

MPSGraph subgraph that expands packed Int32 xgrammar bitmasks into
Float16 logits masks entirely on GPU. Blocked tokens get -65504
(Float16 min), ensuring they are never selected by argmax or topK.

Both MPSGraphArgmaxSampler and MPSGraphCompositeSampler gain:
- bitmaskBuffer (lazy): shared-memory MTLBuffer, allocated on first use
- constrainedExecutable (lazy): compiled graph with bitmask input,
  built on first applyBitmask: true call
- encode(..., applyBitmask:) overloads on all encode paths

Zero overhead for unconstrained generation — no bitmask buffer
or constrained graph is compiled until the first constrained call.

Existing protocol methods and behavior unchanged.

Part 2 of 4 for GPU-based constrained sampling (#114).

* Fix Swift format
stikves added 4 commits August 4, 2026 12:38
Enable grammar-constrained generation (JSON schema enforcement) on the
pipelined engine by applying xgrammar bitmasks directly in the MPSGraph
GPU sampler, eliminating the per-token logit transfer to CPU.

Key components:

- ConstrainedGenerationCapable protocol: capability signal for routing
  constrained generation to engines that support GPU-side bitmask
  application. Replaces concrete type checks with protocol-based dispatch.

- ConstrainedSessionHandle: encapsulated, narrow-API handle to the
  grammar session. Private session storage with only the 5 operations
  the engine loop needs exposed. Single-writer safety guaranteed by
  the engine acquire/release gate.

- PipelinedConstrainedDecodingStrategy: AsyncSequence-based strategy that
  drives the GPU constrained loop. SingleUseFlag iteration guard, proper
  error propagation, Task.checkCancellation, early-stop on consumer drop.

- Session cache (checkout/checkin): sessions reused across calls with the
  same schema. Engine Task defer block owns return-to-cache, ensuring the
  handle is never accessed concurrently.

- Bitmask expansion in MPSGraph samplers: bitwise AND + notEqual + cast
  graph with zero measurable overhead. Both ArgmaxSampler and
  CompositeSampler support constrained path via applyBitmask parameter.

- ConstrainedGenerationSession.fillBitmask(into:): zero-copy write of
  grammar bitmask directly into GPU-visible shared MTLBuffer.

- Integration tests via MockConstrainedEngine conforming to the protocol,
  exercising session cache, schema invalidation, stop sequences, error
  propagation, and maxTokens boundary without Metal hardware.
…safety

- precondition assert in generateConstrained ensures no prior generation
  is in flight when called directly (the public generate() path already
  drains, but protocol callers could skip it)
- One-line comment on logits buffer binding clarifying sequential safety
…llback, masking

- Fix logits buffer overflow on prefill: size to prefillTokens.count, not 1
- Fix jump-forward dropping lastToken from model context: prepend it to
  jumpTokens so the model sees the full sequence
- Enable rollback: set maxRollbackTokens=64, make C bridge return bool,
  guard tokenizeJumpForward on rollback budget
- Clear history after constrained reset to prevent KV corruption on next call
- Fix capabilities: check ConstrainedGenerationCapable conformance, not
  just supportsLogits
- Replace precondition with throw for in-flight check (recoverable error)
- NaN-safe masking: use graph.select instead of multiply-add (also faster,
  eliminates 3 graph ops)
- Elevate multi-token stop sequence warning to level=0 (always visible)
- Set innerIterator = nil on finish/error to release the stream and
  trigger onTermination (prevents session leak)
- Remove outdated LLMAsset reference from error message
}

/// Whether the loaded engine supports GPU-side constrained generation, or `nil` when unloaded.
var loadedEngineIsConstrainedCapable: Bool? {

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.

what about staticShapeInferenceEngine? does this affect that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants