Skip to content

Batched-decode engine runtime (attention-mask path) + benchmark - #335

Merged
stikves merged 1 commit into
apple:mainfrom
stikves:sukru/public-batched-stage2-engine
Oct 21, 2026
Merged

stikves merged 1 commit into
apple:mainfrom
stikves:sukru/public-batched-stage2-engine

Conversation

@stikves

@stikves stikves commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

This PR adds the engine runtime for batched LLM bundles that take an additive attention mask, and
uses llm-benchmark as the first consumer. The scheduler and server that would drive continuous
batching are not part of this PR.

It builds on the batched export from #334, the KV-state fix from #354 and the 8-bit KV-cache support
from #356, all now on main.

What this adds

  • CoreAISequentialEngine
    • decodeBatch runs one forward step over N rows and returns each row's last-token logits.
      When the rows sit at different cache positions (decode only, and only on graphs with an
      attn_mask input), every row writes its new token at the longest row's slot, and the shorter
      rows are copied back to their own position afterward.
    • lockstepBatchedGenerate generates from N equal-length prompts together, with per-row sampling
      configurations. The benchmark uses it.
    • prefillRow prefills one prompt in a batch-1 scratch session and copies its KV prefix into one
      row of a batched cache. When the bundle has a prefill graph, the prompt runs through that chunked
      executable, so the decode executable's heap is not resized for each new row.
    • The engine accepts a third attn_mask input and fills a causal mask for each step.
    • The in-flight generation token, the prefix-hit count, the logits buffer and the input handler
      now live on GenerationSessionState, so separate sessions no longer cancel each other or share
      buffers. The engine's lastPrefixHitCount reports the most recently driven session.
  • Handlers
    • BatchedInputBuilder builds the batched token IDs, position IDs and additive mask, including
      the per-row variants for ragged decode.
    • GrowingNDArrayState can hold a batch of rows. copyRegion moves one row's slice between
      sequence slots, and copyRowPrefix copies a batch-1 prefix into one row. Both copy raw bytes, so they
      work for 8-bit KV caches as well as fp16, bf16 and fp32. The sequence dimension
      is now always taken as the second-to-last dimension, so a dynamic batch dimension is not
      mistaken for it.
    • The internal BatchedDecodeStep protocol describes the batched step and row admission, with a
      usage example.
  • BatchedSampler samples one token per row, each with its own configuration. The single-row
    path in SequentialIterator now calls it with one row.
  • Bundle: LanguageConfig and LanguageModelBundle read max_batch_size, which defaults to 1
    when absent. ModelBundle accepts metadata_version 0.3 as well as 0.2.
  • llm-benchmark: new --batch-size and --inference-engine-variant flags. Batches larger
    than 1 need --inference-engine-variant coreai-sequential, and a batch larger than the bundle's
    max_batch_size is rejected.

Scope and behavior

  • Existing single-sequence bundles run as before. Without --batch-size, the benchmark reads the
    batch size from the graph's fixed leading dimension, which is 1 for these bundles and for
    dynamic-batch graphs.
  • prefillRow, maxBatchCapacity and the ragged path in decodeBatch are there for a future
    scheduler. Nothing in this PR calls them.

Public API

  • The only new public declaration is InputLayout.attnMaskName, next to the existing input-name fields.
  • Everything else this PR adds is package (read by llm-benchmark) or internal: the batched engine
    methods, BatchedSampler, BatchedDecodeStep, the per-row KV copies, and maxBatchSize.
  • The public LanguageConfig initializer is unchanged. max_batch_size is read only when decoding
    bundle metadata.

Validation

  • The package and tests build cleanly, and swift format lint --strict is clean on the changed files.
  • The full Swift test suite passes. A few existing timing tests (Top-K sampler latency, profile-span
    min/max, NDArray allocation cost) can fail when the whole suite runs in parallel; they pass on their
    own, and this PR does not touch the code they time.
  • New tests cover the batched input builder (including ragged decode), the batched sampler, KV-cache
    shape resolution, the per-row copies (fp16, fp32 and int8, with leading and non-leading batch dims), per-session generation state, the additive causal mask,
    and parsing of metadata_version 0.3 and max_batch_size.
  • Greedy decoding on Qwen3-0.6B (64 tokens) gives byte-identical output to main on the default,
    coreai-sequential and coreai-pipelined engines, for:
    • a fresh dynamic export from main,
    • an older dynamic bundle,
    • a static-shape bundle, on the default engine only. The other two engines do not accept static
      bundles on main either.
  • A dynamic-batch bundle exported from main with --attention-mask batched --dynamic-batch-size 4
    (metadata 0.3) runs through llm-benchmark at about 99 tok/s at batch 1 and about 220 tok/s
    aggregate at batch 4 (debug build, M2 Max).

@tjia1818 tjia1818 closed this Oct 12, 2026
@stikves stikves reopened this Oct 19, 2026
@stikves
stikves force-pushed the sukru/public-batched-stage2-engine branch from 34ab73d to 085c80b Compare October 19, 2026 21:37
Add the engine runtime for batched LLM bundles that take an additive attention
mask, with llm-benchmark as the first consumer.

- CoreAISequentialEngine: decodeBatch runs one forward step over N rows, and
  also handles rows at different cache positions during decode when the graph
  has an attn_mask input. lockstepBatchedGenerate generates from equal-length
  prompts together. prefillRow prefills one prompt and copies its KV prefix
  into one row of a batched cache. The engine accepts an optional attn_mask
  input and fills a causal mask for each step.
- Per-request state (in-flight token, prefix-hit count, logits buffer, input
  handler) moves to GenerationSessionState, so separate sessions no longer
  cancel each other or share buffers.
- Handlers: builders for batched token IDs, position IDs and masks; a KV cache
  that holds N rows, with copyRegion and copyRowPrefix for per-row copies; and
  the BatchedDecodeStep protocol. The per-row copies move raw bytes, so 8-bit
  KV caches work as well.
- BatchedSampler samples one token per row with per-row configurations. The
  single-row iterator now goes through it with one row.
- Bundle: LanguageConfig and LanguageModelBundle read max_batch_size, which
  defaults to 1. ModelBundle accepts metadata_version 0.3 as well as 0.2.
- llm-benchmark: new --batch-size and --inference-engine-variant flags.
  Batches larger than 1 need --inference-engine-variant coreai-sequential.

Existing single-sequence bundles run as before. prefillRow, maxBatchCapacity
and the ragged decode path are there for a future scheduler and have no
caller yet.
@stikves
stikves force-pushed the sukru/public-batched-stage2-engine branch from 085c80b to 9a307a6 Compare October 21, 2026 03:00
@stikves
stikves marked this pull request as ready for review October 21, 2026 03:01
@stikves
stikves requested a review from a team as a code owner October 21, 2026 03:01
@stikves stikves self-assigned this Oct 21, 2026
@stikves
stikves merged commit 70daf4e into apple:main Oct 21, 2026
3 checks passed
@stikves
stikves deleted the sukru/public-batched-stage2-engine branch October 21, 2026 23:41
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.

3 participants