Skip to content

context: keep a fused op when only its layer's placement disagrees (-fcd) - #194

Merged
bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/fused-cross-device
Nov 4, 2026
Merged

bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/fused-cross-device

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 4, 2026 •

Copy link
Copy Markdown

Root cause

llama_context::resolve_fused_ops (src/llama-context.cpp) enables a fused op only if every fused node sits on
the same device as its layer:

if (device_fused != device_layer) { ... device_mismatch = true; break; }
if (device_mismatch) { enabled = false; ... }     // once, for the WHOLE op

One mismatching layer therefore disables the op for all layers. On a model that does not fit the device, that is every
configuration: -ngl N leaves layers on the CPU, so Flash Attention, the DSV4 hyper-connection trio and the Lightning
Indexer are all turned off and the graph is rebuilt unfused — hundreds of extra nodes and a CPU<->device split per
one of them.

The rule is deliberate (a fused op spanning two devices pays a copy per execution), but there was no way to override
it. Flash Attention has an accidental one — an explicit -fa on sets auto_fa=false, so the probe never runs — while
auto_fhc/auto_flid are hard-coded true in the context ctor and have no flag at all. So the hyper-connection and
indexer kernels the HRX backend has matchers for cannot be reached at partial offload by any command line.

Fix

One opt-in switch, default off = today's behaviour:

-fcd, --fused-cross-device / -no-fcd, --no-fused-cross-device      (env: LLAMA_ARG_FUSED_CROSS_DEVICE)

With it set, a placement mismatch is still logged (as kept enabled across devices instead of set to disabled) but the
op stays enabled and runs on the backend that claimed it. llama_context_params::fused_cross_device is placed at the end
of the struct with the other booleans, where the struct's own comment asks for them, so no field shifts and the default
is false in llama_context_default_params.

Files: include/llama.h, src/llama-cparams.h, src/llama-context.cpp, common/{common.h,common.cpp,arg.cpp} — 6 files,
+28/−1.

Measured (Qwen3-0.6B-Q4_K_M, 29 layers, 564-token prompt, one gpu-hold, warm JIT, -c 1024 -b 256 -ub 256 -t 4 -fit off -n 32)

arm layers offloaded FA verdict graph splits bs256 / bs1 pp t/s tg t/s
-ngl 99 29/29 enabled 1 1619.1 172.0
-ngl 4 4/29 not supported -> disabled 113 / 313 1002.2 18.4
-ngl 4 -fa on 4/29 rule skipped 1 / 201 1784.6 25.5
-ngl 4 -fcd 4/29 kept enabled 1 / 201 1897.5 25.6
-ngl 20 20/29 not supported -> disabled 113 / 185 1102.3 34.3
-ngl 20 -fcd 20/29 kept enabled 1 / 73 2135.6 65.3

The four -ngl 4 / -ngl 20 rows are medians of three runs per arm. Raw pp/tg per repeat:

arm pp t/s (3 runs) tg t/s (3 runs)
-ngl 4 1002.2, 727.6, 1096.7 18.4, 17.9, 23.9
-ngl 4 -fcd 1717.8, 1932.6, 1897.5 25.6, 23.6, 31.4
-ngl 20 1102.3, 1076.1, 1166.5 31.2, 34.3, 36.1
-ngl 20 -fcd 1797.7, 2153.4, 2135.6 60.6, 65.3, 69.8

Every one of the three paired repeats agrees in direction on both metrics, so the effect survives the box's noise (within-arm
spread is up to ±25 % pp, which is why medians and pairs are used rather than a single number): pp +89 % / tg +39 % at
-ngl 4, pp +94 % / tg +90 % at -ngl 20. The prefills also collapse from 113 graph splits to 1 at bs=256 in every run.

-fcd is -fa on plus the warning. At -ngl 4 the two agree to 3.7 % pp / 0.4 % tg (single runs, same session) — they
differ only in whether resolve_fused_ops still runs and logs the mismatch.

One caveat kept: the -ngl 99 reference is this build's, not the fastest one. An 11-03 measurement of the same arm on a
no-add-on build gave 2726/292, so the add-on's dense-prefill/FA dispatch is slower on this 564-token shape. That is not this
PR's subject; the claim is only the same-build A/B pairs.

-fa on and -fcd are expected to land in the same place: they differ only in whether the warning is still emitted.
Perplexity gate on the same 4 chunks of wikitext (keeping an op must move speed, not quality):

arm PPL
-ngl 99 20.0200 +/- 2.19048
-ngl 4 20.0200 +/- 2.19048
-ngl 4 -fcd 20.0195 +/- 2.19042
-ngl 99 (repeat) 20.0200 +/- 2.19048

Identical to four decimals, so keeping the fused op moves speed and not quality. Note the direction of the mismatch in this
harness is the other one — llama-perplexity at -ngl 99 reports "layer 25 is assigned to device HRX0 but Flash Attention
is assigned to device CPU", i.e. the node fell to the CPU while its layer is on the device; the flag keeps the fused op
enabled there too and it runs on the CPU, which is why the perplexity is unchanged rather than improved. Both directions are
the same code path.

Why this matters beyond the 0.6B

On the DeepSeek-V4.1-Flash lane the fused ops are claimed by HRX but discarded by this rule at every rung a 124 GiB box
can hold: full offload needs 112.4 GiB of device model buffer and ggml-org#680 measured a device fault at GTT 106.5 of 120 GiB,
so the safe maximum is ~37/41 layers — and 4 CPU layers are enough to disable all of it. With the flag the same hardware
can finally measure what those kernels are worth. Nothing here changes any default, so no other model or backend moves.

Refs (context, not closed by this PR): engine ggml-org#680 (the GTT fault point), ggml-org#788/ggml-org#900/ggml-org#920 (the HRX memory work).

…fcd)

resolve_fused_ops disables a fused op for EVERY layer as soon as one of its
nodes sits on a device other than its layer's: one mismatching layer in a
41-layer model turns off Flash Attention, the DSV4 hyper-connection trio and
the Lightning Indexer for all 41, and the graph is then built unfused.

That is the right default (a fused op spanning two devices pays a copy per
execution) but there was no way to say "keep it anyway", except for FA, where
an explicit -fa on skips the probe by setting auto_fa=false. auto_fhc and
auto_flid are hard-coded true in the context ctor and have no flag at all, so
the hyper-connection and indexer kernels cannot be reached at partial offload
by any command line.

This adds one opt-in switch, default off = today's behaviour byte for byte:

  -fcd / --fused-cross-device   (also -no-fcd, LLAMA_ARG_FUSED_CROSS_DEVICE)

When set, a placement mismatch is still logged, but the op stays enabled and
runs on the backend that claimed it. The field lives at the end of
llama_context_params with the other booleans, as that struct asks.
@bong-water-water-bong

Copy link
Copy Markdown
Author

Evidence added after opening, on the same head (9c066e0):

  • ninja test-arg-parser && ./bin/test-arg-parser -> test-arg-parser: all tests OK, rc=0. The new option parses and does not disturb the existing ones; no test enumerates the option set, so nothing else in tests/ needed touching.
  • Default-off is exact, not approximate: the default arms in the table reproduce the pre-patch measurements of the same arms on the same model from an earlier session to the printed digit (-ngl 20: pp 1102.3 vs 1102.3 t/s; -ngl 4: graph splits 113/313 vs 113/313). Nothing moves unless the flag is passed.
  • -fcd and -fa on agreeing to 3.7 %/0.4 % is the intended result: -fa on sets auto_fa=false so resolve_fused_ops never runs, while -fcd runs it, logs the mismatch, and keeps the op. Same placement, one extra warning.
  • Caveat repeated for the record: one run per arm and a loaded box (other lanes were running), so read the same-rung pairs, not the absolute numbers. Happy to re-run any arm if a reviewer wants a repeat.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

VERDICT: READY on the code (default path is byte-identical to the pin); the pp/tg table is the lane's, not mine.

Reviewed at 9c066e007.

What I verified myself:

  • The default can move nothing. With fused_cross_device == false the new branch is
    if (device_mismatch && !cparams.fused_cross_device), which is exactly the old if (device_mismatch), and the else
    branch then runs enabled = true with the extra LLAMA_LOG_INFO line. So a run without the flag builds the same
    graph as the pin; the only difference is one log line per kept op, and only when the flag is on.
  • The struct change is positionally consistent. I extracted llama_context_params from include/llama.h and the
    designated comments from llama_context_default_params() and compared them in order: 37 fields, 37 initializers, and
    every index matches across the insertion. The two apparent mismatches (samplers/sampler, n_samplers/n_sampler)
    are the pre-existing alias and are on the pin too. Nothing shifts value-to-field silently.
  • Compiles: common/arg.cpp, common/common.cpp, src/llama-context.cpp with the recorded GGML_HRX=ON flags ->
    rc 0, 0 warnings each.
  • The plumbing is complete for every entry point I could find: common_params field, the -fcd/-no-fcd pair with
    LLAMA_ARG_FUSED_CROSS_DEVICE, the mapping in common_context_params_to_llama, llama_cparams, and the ctor
    assignment — which is placed next to the other cparams.* defaults, before anything could overwrite it.

Two notes, neither a gate:

  1. The body says the field is "placed at the end of the struct with the other booleans". It is not at the end: it goes
    after kv_unified and before the [EXPERIMENTAL] sampler block, which is where the booleans live and is the right
    place — but this struct is ABI, and adding a field before sampler/n_sampler moves their offsets. Nothing on this
    box links a prebuilt libllama against a newer header, so it is harmless today; the sentence should not claim
    "no field shifts".
  2. Not reproduced by me: the pp/tg table (-ngl 20 -fcd 2135.6 / 65.3 vs 1102.3 / 34.3, three paired repeats) is an
    HRX device run and there is no working HRX build on this box after the reboot for me to re-take it. The direction is
    consistent with the graph-split counts, which the same log records (113 splits -> 1 at bs256), and with the rule the
    code overrides — worth knowing that the numbers rest on that log.

@bong-water-water-bong

Copy link
Copy Markdown
Author

On V4.1 with #190 + #182 stacked under this patch, and what it says about the flag

Local measurement stack (not pushed as a PR — it carries two other lanes' commits): a5c32fbf8 + this patch + #190
(engine ggml-org#920) + #182 (engine ggml-org#900), real HIP add-on, llama-cli 10719, -dev HRX0 -ngl 8 -fcd -fit off -c 1024 -b 16 -ub 16,
GGML_HRX_HOST_WEIGHT_LIMIT_MB 8192-24576, one gpu-hold per group.

The flag behaves exactly as designed on this model:

resolve_fused_ops: layer 0 is assigned to device CPU but Flash Attention is assigned to device HRX0
resolve_fused_ops: Flash Attention kept enabled across devices (fused_cross_device)
sched_reserve: graph splits = 520 (bs=16), 573 (bs=1)
placement: 5480 nodes, 387 off HRX — RMS_NORM 140, CONCAT 46, MUL_MAT 41, ROPE_BACK 40, FLASH_ATTN_EXT 38,
           GET_ROWS 23, SET_ROWS 12, FILL 4, CONT 1

i.e. the unfused graph's CONCAT 319 / CONT 132 / FILL 165 / SOFT_MAX 40 and the whole DSV4_HC_* trio are gone from the
fallback list, against 28,168 nodes / 1,051 off HRX for the rule on the same rung.

And it is not the cause of the model's remaining blocker. At a fixed budget of 24576 MiB the rule and -fcd refuse with
byte-identical host-weight numbers (19782 MiB resident + 3875 MiB parked, the 843 MiB weight that does not fit), and -ub 8
changes nothing either. So the arena's per-execution working set on this file is neither ubatch-proportional nor produced by
cross-device fused execution — it is the rung's host-weight path, and it scales with the budget (8192 → 24576 raises
resident 1.9 → 19.8 GiB while the shortfall stays ~74 MiB). Full table posted on engine ggml-org#900.

What that means for this PR: the speed effect measured on Qwen3-0.6B (pp +89 %/tg +39 % at -ngl 4, pp +94 %/tg +90 % at
-ngl 20, medians of three) is still the only speed claim, and it stays the only one — V4.1 cannot produce a tok/s row with
or without the flag until the host-weight working set is bounded, so nothing here is a V4.1 speedup. Default-off is
unchanged and re-verified against the pre-patch numbers in the body.

@bong-water-water-bong
bong-water-water-bong merged commit c75e808 into 1bit/hrx-vulkan-patched Nov 4, 2026
bong-water-water-bong added a commit that referenced this pull request Nov 4, 2026
`-fcd/--fused-cross-device` (#194) is declared with `set_examples({..., LLAMA_EXAMPLE_BENCH, ...})`
in common/arg.cpp, but llama-bench parses its own closed argument set and rejects anything not in
its whitelist, so the flag is unreachable from the one tool that measures the placement it changes:

    $ llama-bench -m model.gguf -fcd -p 512 -n 32
    error: invalid parameter for argument: -fcd

That leaves the tool unable to reproduce the configuration #194 exists for (partial offload, where
the fused op's layer stays on the CPU). Carry the flag the way the neighbouring placement flags are
carried: a `cmd_params` field with the same default the library uses (off), the pair of switches, the
instance field, `cparams.fused_cross_device` in `to_llama_cparams()`, and a results column that only
appears when it is not the default.

Verified on gfx1151 (HRX0), Qwen3-0.6B-Q4_K_M, -ngl 99 -p 64 -n 8:
  before: "error: invalid parameter for argument: -fcd"
  after : pp64 7497.98 t/s, tg8 361.93 t/s, "fused_cross_device | 1" in the header

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
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.

1 participant