Repository navigation
perf: Apple7/8 hyper-connection norm with its values staged - #46
Merged
paperniuk merged 1 commit intoNov 5, 2026
Merged
Conversation
The injecting hyper-connection norm of decode's rows runs a threadgroup per row whose quarter q runs injection dot q over every stream, so each of the four quarters read all four streams of x and of the norm weights and worked out the same normalized values again. hyper_connection_norm_staged works out each stream's values once, in that stream's quarter, and leaves them in threadgroup memory, two streams at a time (20 KB; four would be 40 KB, over 32 KB), where every quarter's thread reads them at its own positions. The dots run in the same order, so the output and weights are bit for bit the quarters kernel's; qwen4-kernels checks the two at 1-64 rows. ExecutionPlans::hyperNormStaged picks it on Apple7/8 while the rows fit in three quarters of the GPU cores. Past that edge the staged kernel runs a second wave where the quarters kernel doesn't slow down: on an M2 Max (38 cores) it is faster up to 33 rows and 6-9% slower from 34 to 64. Other families, and more rows, keep the quarters kernel. Both kernels give the same bits, so the choice doesn't change any output. M2 Max 38 cores, 1.5.0-m1 -> this, A B B A, decode-profile, Qwen3.8-Flash-Next 2K, int8 KV, eight verify rows a lane (B1-B3 = 8-24 rows take the staged kernel, B4 = 32 rows keeps the quarters kernel). The norm, ms a cycle: 2.68-2.71 -> 2.10-2.17 at B1, 0.50-0.61 less at B1-B3 on every model and context, B4 the same. Fused ms B1/B2/B3/B4: Q2_0 60.43/90.86/117.51/144.89 -> 59.63/90.45/116.92/145.03 (-1.3/-0.5/-0.5/+0.1%) Coder 71.16/103.72/134.74/162.64 -> 70.40/102.75/133.92/162.47 (-1.1/-0.9/-0.6/-0.1%) IQ3_XXS 70.53/104.76/133.64/162.31 -> 69.63/104.00/132.57/162.59 (-1.3/-0.7/-0.8/+0.2%) Q2_0 32K 62.39/94.48/121.98/149.75 -> 62.01/93.86/121.24/150.87 (-0.6/-0.7/-0.6/+0.7%) B4 runs the same kernels on both sides, so its -0.1..+0.7% is the noise. Qwen3.8-27B Q4_0 (dense, no hyper connection): 0.0..+0.4%, the same.
Owner
|
Thanks, merged. Measured together with #45 above. The core count limit is fine with me here, since both kernels give the same bits. |
airiclenz
added a commit
to airiclenz/splash
that referenced
this pull request
Nov 7, 2026
Bring the port of upstream Splash 1.6.0 (with the Apple7/8 kernels and prefill rules, and paperniuk PRs paperniuk#40, paperniuk#43 to paperniuk#46 and paperniuk#49) onto the fork. Upstream's memory ledger and Cache::take, EngineMemory.cpp split, server supervision (RestartPolicy, Quarantine, retry-after), dialects package, StopSearch, json::Writer status, RuntimeMetrics.hpp, kernel selection and calibration tables, and the engine test split win; the fork's changes are re-applied on top. Kept: /version and build identity, --contended-prefill-rows per-family default, --no-speculation, --reasoning-loop-limit, --max-reasoning-effort, --record-requests and GET /requests, --log-token-ids, engine retry of a request with no output (a failure that served only re-runs neither counts toward the six restarts nor starts them anew: RestartPolicy.after takes counted=False), decoder recovery, held-tail decode, stop matching only after reasoning (on StopSearch), the /health refresher, status without deep copies, eviction and denial causes on /status and /metrics (host inactive/wired/compressor bytes, host/engine denial split), the no-allocation latency percentiles, encode-overhead BufferList and PipelineName beside upstream's address tables, prefill graph-size hints, http_regression and the agent_replay, pressure_probe, contended_probe and live_probe tools. Wire protocol 13: upstream's 12 (Stop frame, Warming, Ready stop tokens, Error retryAfterSeconds) plus the fork's Done timing (EngineRequestTiming, Done 102 bytes). Goldens regenerated. Eviction causes are ported onto the ledger: VictimQuery::cls set at the three ledger sites, Cache::take maps Host to pressure_pass, a hostRefused need to paused_backing, any other class to growth, and reserveCapture scopes snapshot (coarser than 1.5: eviction-cause-precision). Early done is now the 1.6 version sent upstream as incoai incoai#704 (00b120b, ccbb72c): done at the stop match, the gate slot held until the drain (release_after_drain). Behaviour change: retry meets quarantine. A request whose first run and re-run both crash the engine gets 503 runtime_unavailable once and request_quarantined after (before: two 503s first). Upstream's quarantine tests are rewritten to this; kills, Metal out-of-memory and queued requests still never quarantine. Dropped as superseded: c3a2929, 3f516c0 Stop frame: upstream's Stop frame, Engine::stop (LaneOutcome::Stopped) and RuntimeCall.stop / _end_call 8984a9e early done: the incoai incoai#704 version above 605841f --contended-prefill-rows forwarding: upstream's EngineArgument 5967821 server defaults: 1.6 has no default request deadline Tests: both sides kept. engine_test.cpp's fork tests moved into the engine_*_test.cpp split (request timing, replay suspensions); the queued-depth tests (6163e03) and pressure_session_test are rewritten on the ledger, since 1.6 removed reclaimOneState/evictAll/reclaimOne and MemoryPressurePolicy; testStateEvictionsAreCountedByCause now covers the cause mapping. 1.5's MemoryPressurePolicy tests (upstream's) go with their API. Fork-only: runtime/engine's line budget (new in 1.6) is raised from 29,500 to 29,550 for the fork's engine timing and causes. The app's launch options gain the fork's five serve options so upstream's launch-options parity test holds. Conflicts: runtime/engine/Protocol.{hpp,cpp}, server/protocol.py: protocol 13 runtime/engine/Engine.{hpp,cpp}: upstream's stop and EngineMemory.cpp split; fork timing, replay end and suspensions re-applied there runtime/engine/Cache.cpp, StateCache.hpp: Cache::take with causes, EvictionScope moved to Cache.hpp runtime/engine/MemoryGovernor.cpp: 1.6 snapshot() with fork host bytes runtime/engine/Status.{hpp,cpp}, Types.hpp: json::Writer with fork keys runtime/engine/NativeRuntime.cpp: upstream, fork timing in Done runtime/metal/CommandGraph.hpp, MetalBackend.hpp, runtime/ops/ PagedAttention.cpp: upstream address tables, fork BufferList runtime/model/Runtime.cpp: upstream, fork speculation and graph hints dev/native.mk: upstream's list, pressure_session_test kept server/backend.py: upstream supervision, fork features re-ported server/server.py: upstream routes, /version, /requests, /health fields, release_after_drain server/runtime.py: upstream server/metrics.py, serve_options.py, frontend.py: both install/launcher.py, dev/tools/package.py: both dev/tests/engine/engine_test.cpp: deleted; fork tests ported to the split dev/tests/engine/*_test.*, backend_benchmark.mm: both dev/tests/engine/{protocol,status}_golden: regenerated dev/tests/server*, test_http_regression.py: both; rewritten on new APIs dev/benchmarks/http_regression.py: fork's (deleted upstream, incoai#694) docs/{api,internals,server,validate}.md: upstream's, fork text folded in Verified on Apollo-II (M2 Pro, GPU family 8): make all; make check passes but for check-app's 9 locale cases (app-tests-locale, en_SE); every native CPU, Metal and sanitizer target builds; dev/tests/server 1189 tests pass; test-real on mlx-community/Qwen3.8-27B-4bit: vision parity and the three oracle runs pass, speculation-off greedy parity included. http_regression ABBA against apple7-m1-kernels (contexts 2048,10000, 5 samples): correctness and performance pass; decode 19.41 -> 19.40 ms per token, cold TTFT +0.06% (2048) and +0.68% (10000), exact-prefix TTFT -1.68% and -2.25%, every change within noise or better. Follow-ups: eviction-cause-precision, quarantine-write-race.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
@paperniuk this one is a small one for Flash-Next decode on M1/M2. In the profile of a verify cycle on my M2 Max the
injecting hyper-connection norm is ~4.5% of a one-lane cycle. Its four quarters each read all four streams of x and of
the norm weights and work out the same normalized values again. This works out each stream's values once and keeps
them in threadgroup memory for the other quarters. Outputs are bitwise the same. The norm is ~20% faster (0.5–0.6 ms
less per cycle), so the cycle is 1.1–1.3% faster at one lane and ~0.6% at two and three. Small, but it is in every
Flash-Next cycle. As before, I used Claude Code to author it and tested it by hand.
What changed
kernels/shared/hyper_connection.metal:hyper_connection_norm_staged. Same threadgroup of 1024 threads per row ashyper_connection_norm_quarters, one quarter per stream. Each stream's quarter works out v = x * scale * norm once,writes the bf16 output and leaves v (fp32) in threadgroup memory, two streams at a time (20 KB, four would be 40 KB).
Then every quarter's thread runs its dot over the staged values at its own positions. The dots run in the same order
as before, so the output and the weights are bit for bit the quarters kernel's. The two kernels share one
hyper_norm_quarter_rowtemplate.ExecutionPlans::hyperNormStaged(rows): true on Apple7/8 whilerows * 4 <= gpuCores * 3.Qwen4ExpTargetpasses it to
addNormfor the prefill and verify norms. Other families, and more rows, keep the quarters kernel.qwen4_kernels_test.mm: runs the staged kernel beside the quarters one and compares output and weights byte forbyte.
execution_plans_test.cc: the row edge per family and core count.Why the row limit: with many rows the staged kernel gets slower than the quarters one. On my M2 Max (38 cores) it is
faster up to 33 rows and 6–9% slower from 34 rows. Staging one stream at a time (10 KB) is slower
everywhere and jumps at the same place, so I don't think it is the threadgroup memory. I don't know the cause. Three
quarters of the cores keeps a margin below that edge. I measured the edge only on the M2 Max, so on M1 the limit is a
guess from the core count. I know you prefer not to pick by core count. Here both kernels give the same bits, so the
limit only decides which one is faster, not any output.
Results (M2 Max, 38-core GPU)
The norm alone, all 96 norms of a Flash-Next Q2_0 step, ms, quarters → staged, A B B A:
decode-profile, Flash-Next, 2048-token prompt, int8 KV, fused GPU ms per cycle at 1 / 2 / 3 / 4 lanes, A B B A,eight verify rows per lane (Q2_0 four runs per side, the others two). 1–3 lanes are 8–24 rows and take the staged
kernel. 4 lanes are 32 rows, over the limit for 38 cores (28 rows), so they keep the quarters kernel and are the same on
both sides:
The norm in the same runs, ms per cycle: 2.68–2.71 → 2.10–2.14 at one lane, 0.50–0.61 less at one to three lanes on
every model and context, the same at four. So the cycle saves about what the norm saves. The four-lane ±0.7% is the
run-to-run noise, it is the same kernels on both sides.
With the verify budget's own rows (noisier, the rows differ between runs):
Qwen3.8-27B Q4_0 (dense, no hyper connection) as a control: 81.36 / 143.25 / 195.30 / 255.81 → 81.66 / 143.26 /
195.47 / 255.89, the same.
Server, Flash-Next Q2_0 with my usual flags, 5 greedy 400-token requests per run, two runs each, A B B A. Per
request in cycles per second (decode tok/s over tok/cycle from the server log, which doesn't depend on how many draft
tokens get accepted): 18.62 → 18.82 on average (+1.1%). 3 of the 5 requests are faster in both runs, the other two
about the same. The log rounds tok/cycle to one decimal, so a single request is only good to ~2%. Median decode tok/s
54.81 / 54.82 before and 55.22 / 57.30 after, but that moves with acceptance more than with this.
Long agent chat replay in one of my tests (78 turns up to 125K context, each sent twice), server log, base → this
PR. Cycles per second is the decode speed without the effect of how many draft tokens get accepted:
The Q2_0 32K row is only 4 requests, and at 30K the two builds wrote different answers (590 tokens at 3.9 tok/cycle
vs 210–228 at 3.1), so the budget verified more rows there and the cycles are not the same work. The one request
with the same answer is 17.09 → 16.82, inside the log's rounding of tok/cycle. Above 32K, where most of the requests
are, it is +0.3…+1.2%, about what the norm saves. Prefill is the same (paired requests −0.3% and +0.0%).
Testing
qwen4-kernelspasses with and withoutMTL_SHADER_VALIDATION, with the new staged vs quarters check (bit for bitwithout validation and in
make check's uninstrumentedqwen4-kernels bitwiserun. Under validation the testcompares the instrumented kernels within its tolerance, like its other checks).
gguf-moe,gguf-moe-e512,gguf-moe-sigmoidandlinear-planpass with and without validation.linear-plan --cpu,execution-plansandmodel-execution-planspass.make checkon the M2 Max:check-sourceandcheck-pythonpass. Incheck-native-enginetwo tests fail hereon the base too, because my disk is nearly full:
disk-spaceandruntime-resources(it wants SSD cache room). SoI ran the CPU tests after
disk-spaceone by one, all pass, and all the Metal tests with validation (asmake checkruns them) and again without. All pass exceptruntime-resources.check-apphung downloading Sparkleagain, as before. It doesn't touch this code.