Skip to content

perf(qwen3): skip redundant decode d2h/host copies in greedy path - #71

Open
lyfne123 wants to merge 2 commits into
hw-native-sys:mainfrom
lyfne123:perf/qwen3-decode-skip-redundant-d2h
Open

perf(qwen3): skip redundant decode d2h/host copies in greedy path#71
lyfne123 wants to merge 2 commits into
hw-native-sys:mainfrom
lyfne123:perf/qwen3-decode-skip-redundant-d2h

Conversation

@lyfne123

Copy link
Copy Markdown
Contributor

Summary

The fused Qwen3-14B decode kernel does device-side embedding and greedy sampling, so several per-step host copies are pure waste. This removes three, all host-only — no kernel change, no recompile. All Qwen entry points default to temperature=0 (greedy device sampling), so this is the hot path in normal runs.

# Copy eliminated Per decode step When
greedy logits d2h 16 × 152064 × 4B ≈ 9.7 MB greedy
next_hidden d2h 16 × 5120 × 2B ≈ 160 KB always
dead decode hidden copy [16, 5120] alloc + copy + float always

How

The runtime only device→host copies host shared-memory args; worker-resident DeviceTensor args stay on device. So:

  • ① logits — in the device-greedy path the token id comes from decode_sampled_ids_buffer (device), and host code never reads the full [BATCH, VOCAB] logits. Point the kernel's logits out slot at a reusable worker-resident DeviceTensor (_decode_device_scratch) instead of the shared host buffer. The kernel's internal argmax stays device-local; the d2h disappears. Non-greedy still uses the host buffer and returns real logits.
  • ② next_hidden — decode never returns it (run_decode always passes None to _integrated_sample_result), so its Out buffer is scratch. Route it to a device-resident buffer unconditionally.
  • ③ dead hidden_prepare_decode_inputs built a [B, hidden] bf16 buffer from batch.hidden_states (a zeros placeholder for device-embedding executors) that the kernel does not consume and no caller reads. Stop building/returning it.

DecodeResult.hidden_states / .logits become Optional; serving_worker reads host logits lazily (only on the non-greedy fallback), so None is safe. engine._sample_batch_rows already short-circuits on device-sampled ids before touching logits.

Verification (NPU, a2a3, greedy temperature=0)

  • Correct output: "The capital of France is""Paris … Washington", 24 tokens over 23 decode steps through the new path, no errors.
  • Run-to-run token divergence was confirmed to be inherent NPU non-determinism: the same unmodified binary run on two devices diverges identically at a near-tie argmax (step ~10). Not caused by this change — logits argmax reads the same values whether the buffer is host or device, and next_hidden is write-only.

Scope

  • Non-greedy / streaming paths unchanged (still return host logits).
  • Prefill's full-embedding h2d is not touched here — it needs kernel-side device gather.

🤖 Generated with Claude Code

The fused Qwen3-14B decode kernel does device-side embedding and greedy
sampling, so several per-step host copies are pure waste. Eliminate three,
all host-only (no kernel change, no recompile):

1. Greedy logits d2h. In the device-greedy path the token id is produced
   inside the kernel (decode_sampled_ids_buffer), so host code never reads
   the [BATCH, VOCAB] f32 logits. Point the kernel's logits `out` slot at a
   reusable worker-resident DeviceTensor instead of the shared host buffer;
   the runtime only d2h-copies host shared-memory args, so the ~9.7 MB/step
   copy (16 x 152064 x 4B) disappears. Non-greedy still uses the host buffer.

2. next_hidden d2h. Decode never returns next_hidden (run_decode always
   passes None to _integrated_sample_result), so its Out buffer is scratch.
   Route it to a device-resident buffer unconditionally, dropping a
   ~160 KB/step copy.

3. Dead decode hidden copy. _prepare_decode_inputs built a [B, hidden]
   bf16 buffer from batch.hidden_states (a zeros placeholder for
   device-embedding executors) that no caller reads and the kernel does not
   consume. Stop building/returning it.

DecodeResult.hidden_states / .logits become Optional; serving_worker reads
host logits lazily (only on the non-greedy fallback), so None is safe.

Verified on NPU (a2a3, greedy temperature=0): correct output
("The capital of France is" -> "Paris ... Washington"), 23 decode steps
through the new path, no errors. Run-to-run token divergence was confirmed
to be inherent NPU non-determinism (the same unmodified binary diverges
identically across devices at a near-tie argmax), not caused by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 287e5e5b-9671-427c-997f-2913e65cabbd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

Device-greedy decode flow

Layer / File(s) Summary
Optional decode results and sampling fallback
python/core/types.py, python/core/serving_worker.py
DecodeResult now permits absent logits and hidden states; serving checks device-sampled tokens before falling back to host logits sampling.
Decode input and scratch-buffer preparation
examples/model/qwen3_14b/runner/npu_runner.py
Decode inputs no longer build hidden placeholders, and reusable worker-resident scratch buffers can receive redirected outputs.
Greedy decode output routing
examples/model/qwen3_14b/runner/npu_runner.py
Device-greedy decode redirects logits to scratch storage and returns None for logits and hidden states; non-greedy decode retains host logits.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ServingWorker
  participant Qwen314BModelRunner
  participant DecodeKernel
  participant DecodeScratch
  ServingWorker->>Qwen314BModelRunner: run_decode with greedy sampling enabled
  Qwen314BModelRunner->>DecodeScratch: get cached logits target
  Qwen314BModelRunner->>DecodeKernel: run decode with logits_override
  DecodeKernel->>DecodeScratch: write device-resident logits
  Qwen314BModelRunner-->>ServingWorker: return DecodeResult with logits=None
Loading

Possibly related PRs

Poem

A rabbit hops where logits once flew,
Into scratch buffers, fast and true.
Hidden states rest, host copies cease,
Greedy tokens bound through device peace.
“Nibble less transfer!” the bunny sings.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed It accurately summarizes the main optimization: removing redundant decode host/device copies in the greedy Qwen3 path.
Description check ✅ Passed The description matches the PR, detailing the three host-copy reductions, optional decode outputs, and unchanged non-greedy paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes the decode path by keeping the decode hidden states and logits device-resident when device-side greedy sampling is enabled, avoiding unnecessary device-to-host (d2h) copies. This is achieved by introducing reusable worker-resident scratch buffers for logits and next_hidden states, making hidden_states and logits optional in DecodeResult, and moving the host-side logits slicing logic into _sample_result_row. Feedback on the changes highlights a potential AttributeError in _sample_result_row if result.logits is None when falling back to host-side sampling, and suggests adding a defensive check to handle this case robustly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +393 to +395
logits = result.logits
logits_row = logits[row_idx] if logits.dim() > 1 else logits
return self.sampler.sample(logits_row, params)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If allow_device_sampled is True but sampled is unexpectedly None (or in any other scenario where result.logits is None), accessing logits.dim() will raise an AttributeError: 'NoneType' object has no attribute 'dim'. Adding a defensive check here to ensure logits is not None before accessing its attributes makes the sampling fallback path much more robust and provides a clearer error message.

        logits = result.logits
        if logits is None:
            raise RuntimeError(
                "Both sampled_token_ids and logits are None. Cannot perform sampling."
            )
        logits_row = logits[row_idx] if logits.dim() > 1 else logits
        return self.sampler.sample(logits_row, params)

…gression)

The decode d2h/host-copy removal is numerically neutral: the kernel's
greedy argmax reads identical logit values whether the logits buffer is
host- or device-resident, next_hidden is write-only, and the removed
decode hidden copy was never consumed. Local reruns pass chunked-prefill
3/3 on this branch and 2/2 on main; the prior CI failure was an inherent
chunked-vs-full-prefill near-tie flip on the first token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lyfne123
lyfne123 force-pushed the perf/qwen3-decode-skip-redundant-d2h branch from c85cabe to 94e8975 Compare July 13, 2026 06:12
@high-cloud

Copy link
Copy Markdown
Contributor

#57 重复了

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.

2 participants