Skip to content

apr run --gpu is 2-3x SLOWER than --no-gpu on the dense path: the GPU result fails correctness (cosine 0.4153) and falls back to CPU after the user has paid for it #3602

Open
2 of 2 issues completed

Description

@noahgift

On the dense path, apr run --gpu is 2–3× SLOWER than --no-gpu, because the GPU result fails its own correctness check and the run falls back to CPU after paying for the GPU attempt. The user pays twice and the warning is on stderr.

Measured by the instrument lane (#3598 row 1) on lambda 4090, GPU idle at start, --max-tokens 1, prompt Hi:

model GPU attempt --no-gpu
qwen2.5-coder-0.5b 32.0 s · 35.8 s 17.5 s
qwen2.5-coder-1.5b 10.4 s 3.7 s

The dense GPU validation fails on correctness — cosine 0.4153 at position 1, via batched prefill — and the run then falls back through wgpu to CPU. So asking for the GPU costs 15–17 s for a rejected attempt and then the full CPU run on top.

Why this is a 0.69 row and not a performance row

0.69's claim is verbs that are true. Two things here are untrue in the sense that matters:

  1. --gpu makes the command slower, and nothing in the product says so. A user who reads "use --gpu for speed" measures the opposite and has no way to learn why. Time is the only signal they get and it points the wrong way.
  2. A correctness failure is being discharged by a fallback. cosine 0.4153 at position 1 is not a near-miss; the dense GPU path is producing wrong logits and the fallback converts that into a latency complaint. The fallback is the right behaviour — better slow than wrong — but it currently hides the defect rather than reporting it, which is the fail-open shape this release is supposed to eliminate. Compare apr chat hard-skips CUDA for every qwen35 model and prints a banner citing #3090 as unimplemented — #3090 shipped a day earlier, and the corrected replacement has zero callers #3595, where a true capability was advertised as absent; this is the mirror, a real defect presented as slowness.

Likely the batched-prefill FP8 class (#3483), on the evidence that the failure arrives via batched prefill. Not asserted — nobody has bisected it, and the instrument lane deliberately did not touch it.

done_when

  1. The correctness failure is surfaced as a correctness failure, not absorbed into wall clock: apr run reports that the GPU result was rejected, the measured cosine, and the position — on a stream the user reads, once, not buried beside a fallback notice on stderr.
  2. The cost of a rejected GPU attempt is attributable — it shows up in the EPIC E2 0.73: Verbs Are Fast #3598 timing fields (a validate_ms/rejected_ms stage), so "GPU made it slower" is readable from the receipt instead of inferred from a stopwatch.
  3. Root cause named for the dense path — whether cosine 0.4153 is FP8 batched prefill fails CPU parity at the post-prompt decode step — catastrophic on per-head-QK-norm models (Qwen3-8B cos −0.10), marginal on Qwen2.5 (0.92) #3483's FP8 batched-prefill class or something else. One bisect, stated either way.
  4. A decision, recorded, on whether --gpu should fail rather than fall back when the GPU result is rejected on correctness. Silent-and-slow and loud-and-failed are both defensible; silent-and-slow-while-a-correctness-check-failed is not. Whichever is chosen, the refusal or the fallback names the reason.
  5. Falsifier: a planted correctness failure must produce the same user-visible report, and a healthy GPU run must not.

Scope

Dense path only — OwnedQuantizedModel / forward_gpu_resident. The hybrid (qwen35) path is a separate finding: its pre-generation cost is f2_validate_qwen35 at 67 % of a 14 s TTFT (#3596), and its decode is ~370 tok/s against llama.cpp's 179–189 on the same box. These two must not be conflated — one is a guard that is too expensive, the other is a GPU result that is wrong.

Found while measuring something else, which is why nothing was looking for it: the fallback works, so no test fails.

Refs #3598, #3596, #3483, #3080.

Activity

  1. added this to the 0.69.0 milestone on Sep 20, 2026
  2. added
    bugSomething isn't working
    P0Critical priority
    on Sep 20, 2026
  3. noahgift commented on Sep 20, 2026

    @noahgift
    MemberAuthor

    Operator ruling 2026-09-20: stays P0 on 0.69, and the 0.69 fix is the reporting, not the kernel

    --gpu failing its own cosine check and silently falling back to CPU is a truth defect: the fallback is correct behaviour, the silence is not.

    0.69 scope — the fallback becomes loud and receipted:

    1. One line naming the failed check and the measured cosine, on a stream the user reads.
    2. An exit code distinct from success. A run that silently substituted a different backend after a correctness rejection must not exit as though nothing happened.
    3. backend: cpu (gpu rejected: cosine 0.4153@1) in --json — the rejection, its reason and its position are fields, not prose.

    0.70 — the batched-prefill correctness bug underneath, unless the bisect turns out to be quick. Carries removed_by: v0.70 on the refusal either way, so it is a named refusal in the 0.69 notes rather than an omission.

    That split is the release doctrine applied literally: 0.69 is verbs that are true; 0.70 is verbs that are fast. A GPU path that produces cosine 0.4153 and quietly hands the work to the CPU is not slow — it is lying by omission, and the lie is fixable this train even though the kernel defect is not.

    Do not conflate this with #3604. Two different pre-generation costs on two different paths:

    path cost nature
    #3604 hybrid qwen35 f2_validate_qwen35, 67 % of a 14 s TTFT a guard that is correct but too expensive
    this dense OwnedQuantizedModel 15–17 s for a rejected GPU attempt, then the CPU run a GPU result that is wrong

    One is an expensive right answer; the other is a wrong answer that costs the user twice. Fixing the receipt cache does nothing for this issue, and fixing this does nothing for TTFT on the hybrid path.

    Refs #3604, #3596, #3598, #3483.

  4. noahgift commented on Sep 20, 2026

    @noahgift
    MemberAuthor

    done_when 3 — the bisect, stated either way

    It is FP8 prefill. Not the FP8 weight cache, and not batched/HGEMM prefill. One knob clears it.

    Lambda 4090, GPU idle at 563 MiB recorded at start, qwen2.5-coder-0.5b-instruct-q4_k_m, prompt Hi, CUDA build from PMAT-3602-gpu-rejection-visible:

    configuration divergence fell back?
    baseline (everything on) cosine 0.4153 yes
    REALIZR_NO_FP8_CACHE=1 cosine 0.4153 yes
    HGEMM_PREFILL=0 cosine 0.4153 yes
    FP8_PREFILL=0 none no

    So the FP8 weight cache is not implicated — disabling it changes nothing — and neither is the batched/HGEMM prefill path, which is the hypothesis the stderr line's "validated via batched prefill" invites. The divergence survives HGEMM_PREFILL=0 and dies with FP8_PREFILL=0.

    This confirms #3483's FP8 class and narrows it: the defect is in the FP8 prefill arithmetic, and it is reachable on the non-HGEMM path too.

    And with FP8 prefill off, the GPU is used and the command gets faster

    configuration 1 token 32 tokens used_gpu
    --gpu baseline 30.6 s / 36.1 s 38.3 s false — rejected, fell through wgpu to CPU
    --gpu FP8_PREFILL=0 17.2 s / 18.8 s 18.8 s true
    --no-gpu 17.2 s 20.8 s false

    The issue's headline reproduces — --gpu at 38.3 s against --no-gpu at 20.8 s — and inverts under one flag: 18.8 s with the GPU actually running, now faster than CPU rather than ~2× slower.

    This is a measurement, not a proposed fix. FP8_PREFILL=0 is a bisect knob; whether the shipped default should change is a separate call that belongs with whoever owns #3483, because the FP8 prefill path presumably exists for a reason on models where it is correct. What the bisect settles is where to look.

    done_when 2 is already satisfied by #3606, not owed here

    validate_ms in #3606 instruments validate_gpu_first_token — which is exactly where a rejected GPU attempt's cost lives on the dense path. Measured there at 15.1 s on this model. So "GPU made it slower" is already readable from the receipt rather than inferred from a stopwatch, and this row does not need to re-add the field.

    Remaining here: item 1 (surface the rejection where a user reads it, and in --json), item 4 (the recorded fail-vs-fall-back decision), item 5 (the falsifier both ways).

    Off the GPU.

  5. noahgift commented on Sep 20, 2026

    @noahgift
    MemberAuthor

    Correction to the comment above — two of its three rows were inert

    I read the knobs' names and not their bodies. Before anything else, what was wrong with my own bisect:

    row I published what that knob actually gates verdict
    HGEMM_PREFILL=0 did not clear it ⇒ "not the batched/HGEMM path" warmup_fp16_cache_if_enabled only — and that function already returns early when gpu_profile.fp8_prefill is true (crates/aprender-serve/src/gguf/cuda/mod.rs:346) vacuous. FP8 was on in every baseline row, so the FP16 warmup was skipped anyway. The knob could not have changed the outcome
    REALIZR_NO_FP8_CACHE=1 did not clear it ⇒ "not the FP8 weight cache" the eager warmup of that cache (…/gguf/cuda/mod.rs:366). FP8 GEMMs still run against a lazily-populated cache overstated. It rules out warmup timing, not the cache
    FP8_PREFILL=0 cleared it ⇒ "FP8 prefill specifically" detect_fp8_decode falls through to fp8_prefill whenever FP8_DECODE is unset (crates/aprender-serve/src/cuda/gpu_profile.rs) not separable. One flag moved both FP8 prefill and FP8 decode

    A gate short-circuited by the very setting under test, a warmup switch read as a cache switch, and a fallthrough coupling two flags. The generative rule: a bisect over env knobs proves nothing until each knob's body is read for what it gates under the baseline's other settings.

    The 2×2 that does separate them

    Every cell names both flags explicitly, so no cell depends on the fallthrough. apr 0.68.2 (e6f77c98c) — the binary built from this branch's HEAD — lambda RTX 4090, 631 MiB idle at start, same model and prompt, --max-tokens 1 --gpu --json:

    # FP8_PREFILL FP8_DECODE wall used_gpu divergence
    1 1 1 34.3 s false position 1, cosine 0.4153
    2 1 0 32.9 s false position 1, cosine 0.4153
    3 0 1 16.9 s true clean
    4 0 0 17.0 s true clean

    The divergence tracks FP8_PREFILL exactly and is completely independent of FP8_DECODE. Row 2 holds FP8 decode off and the divergence is unchanged to four decimals; row 3 holds FP8 decode on with prefill off and it is clean. Row 3 is the discriminating cell, and it is the one my first matrix never ran.

    So the original headline was right and is now established rather than asserted: it is FP8 prefill. This confirms #3483's class.

    The mechanism that should have caught this already declined to fire

    f2_should_retry_without_fp8 (crates/aprender-serve/src/infer/inference_result.rs:1078) exists for exactly this: on an FP8 miss it re-measures on the FP16 prefill path, and f2_remeasure_without_fp8 persists fp8_prefill = false for the run when FP16 passes. That is a precision fallback instead of a backend fallback — the right design, already built.

    It did not fire here, and its note: … re-measuring on the FP16 prefill path line is absent from all of stderr. One conjunct is sufficient to explain that:

    && report.min_cosine_real >= F2_CATASTROPHIC_COSINE   // 0.4153 >= 0.90 is false

    The band's stated purpose is that "anything below the catastrophic band is never retried", justified by Qwen3-8B under FP8 at −0.0972. But it lumps 0.4153 in with −0.0972, and rows 3–4 show 0.4153 is a case where FP16 is clean and the retry would have succeeded — keeping the model on the GPU at half the wall time (16.9 s vs 34.3 s) instead of pushing it to CPU.

    Recommendation, for whoever owns #3483: drop the min_cosine_real/first_bad_cosine conjuncts and retry whenever !accepted && fp8_prefill_on (and the path matches). The fear the band guards against — "hiding a real divergence behind a second measurement" — does not apply, because the retry re-measures against the same F2_GATE_COSINE_MIN floor on a different configuration that then persists. A failed retry still rejects; the floors never move. All the band buys is one skipped probe on hopeless cases, and it costs a 2× regression on this one.

    I am not making that change here — it is #3483's call and its owner should have the table. Posting it there.

    Still owed on this issue

    Item 3 is now answered. Item 2 remains satisfied by #3606's validate_ms (15.1 s on this model). Items 1, 4 and 5 are mine and in progress.

    Off the GPU.

  6. modified the milestones: 0.69.0, 0.70.0 on Sep 21, 2026
  7. noahgift commented on Sep 21, 2026

    @noahgift
    MemberAuthor

    slipped_from: 0.69.0

    §4.1 freeze (06x-release-schedule.md §1: "scope that is not on the platform rides the next train"). Operator, 2026-09-21, verbatim: "just by these optimization you should be able to release 0.69 today, ensure this happens". The 0.69 platform is #3669 plus the bump.

    Why this row rides 0.70: item 1 lands in #3669 (#3638: a CPU fallback is reported as a refusal). Item 2 is owed to #3606, which is not on the platform.

    — aprender-04 (cop)

  8. noahgift commented on Sep 21, 2026

    @noahgift
    MemberAuthor

    Root causes (aprender-37, measured 2026-09-21), two DIFFERENT defects in the FP8 BATCHED prefill GEMM. "lambda-only" was a path artifact: gx10 defaults to serial prefill (select_prefill_path: cc≥120 → Serial; cc 89 → Batched). Forcing batched on gx10 reproduces the same failure (cos 0.4159).

    1. qwen2.5-coder-0.5b (cos 0.4153, this issue's number): a stale PMAT-084 FP8 activation cache. The key is (input_ptr, count), cleared only on scratch realloc. In a mixed-quant model only ffn_down takes FP8, so layers 1–23 HIT layer 0's entry (23 hits, all ptr=0x71aec6600000 count=238336) and reuse layer 0's activation.
    2. qwen2.5-coder-7b (cos 0.5668 at pos 1): FP16 overflow in the FP8 GEMM output D, written in FP16 as true × 448/act_absmax before the scale is applied. Layer-1 QKV peaks at D = 85,807 > 65,504. The 1.5b peaks at 6,822 and the 0.5b at 3,576, which is why the 1.5b is green.
      Knobs: FP8_PREFILL=0 or BATCHED_PREFILL=0 turn both green. REALIZR_NO_FP8_CACHE=1 is NOT an off switch, and APR_FP8_PAD_ZERO=1 doesn't help (so cublas_prefill_fp8_gemm reads 16 rows of FP8 scratch where 1 was written: the padded tail is the previous GEMM's activations #2765's padded tail isn't the cause).
      Ruling (cop): fix both for 0.69.1: the cache key must be un-aliasable across layers, and D goes in BF16/FP32 or is scaled before the FP16 store. Each gets a red-turning test and is proven on lambda AND gx10 with batched forced. No default-off workaround.
  9. 38 remaining items

  10. noahgift commented on Oct 14, 2026

    @noahgift
    MemberAuthor

    P0 to P1 (2026-10-14 P0 triage): P0 now means it blocks the next release or carries the operator's own words on it; this issue does neither, so it stays on the 0.71.0 milestone at P1. The operator quote in its history is a milestone-wide triage note, not words about this issue.

  11. noahgift commented on Oct 14, 2026

    @noahgift
    MemberAuthor

    merged into #5312 (master ticket for epic #3994; triage rule 7b, 2026-10-14). It is a checklist row there; reopen this issue when work on it starts or its PR opens.

  12. noahgift commented on Oct 14, 2026

    @noahgift
    MemberAuthor

    Moved: this sub-ticket is now a row in #5323 (CUDA quantized kernels: correct block layouts, cache keys and precision), split out of #5312 by theme.

  13. modified the milestones: 0.71.0, 0.73.0 on Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High prioritybugSomething isn't workingowner:aprender-w4664Owner session aprender-w4664size:MK̂ 3-4 h

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions