The GDN prefill convolution writes q/k/v itself: 32 GB of copies gone per 32K prompt, 128 MiB of workspace freed - #99
Conversation
Thank you for splitting this change out of #96. This PR is much easier to review. The overall direction is sound: having the convolution write q/k/v directly, removing the intermediate buffer and the three extraction operations, and reducing workspace are all valuable. The numerical qualification and route-boundary coverage are also thorough. There are still a few points that should be tightened before merging:
With these changes, I expect this PR to be a strong merge candidate. Once the requested changes are ready, please squash the PR into a single commit and rebase it onto the latest |
The chunked prefill convolved into one packed [C,T] buffer and then pulled the three channel ranges out of it with three cudaMemcpy2DAsync per layer per chunk. The convolution already knows which channel each thread owns, so it can address the destination directly. causal_conv1d_silu_split does that. The prefill and small-T kernels take an output address map - one contiguous destination, or three partitioned by row - following the form w8_rowsplit_output.cuh already uses: the three row counts are template parameters, so the partition boundaries and the leading dimensions are constants inside the kernel and the pointers carry __restrict__. The wrapper resolves the destination row profile to one of the two registered geometries and rejects anything else, which is also what removes the caller workspace: the packed [C,T] plane leaves the target's workspace recipe. Route selection is measured, not assumed. Across T = 1..72 on both geometries the small-T kernel holds a flat 4.1 us to its bound and the prefill pair is flat at 8.2 us above it, while the sequence kernel that both entries selected in between cost 12.3 us at T = 17 and 28.7 us at T = 64. Nothing else in the tree used that kernel, so the route is removed rather than bypassed, and the packed entry gets the same 1.5x to 3.5x on that interval. At T = 1 the small-T route and the dedicated decode kernel both measure 4.1 us, so the split entry does not add a decode route. The state rule the family contract already stated - conv_state_in and conv_state_out are disjoint or exactly the same storage - is now enforced on both distinct-state entries. It was unchecked on the packed one, where a partially overlapping pair reached a kernel that declares both pointers restrict.
003449c to
289cc4b
Compare
|
Reworked against all four points, rebased onto
Two things are yours to decide rather than mine. The route removal changes the packed and snapshot Unrelated to this branch: |
The chunked GDN prefill convolved into one packed
[C,T]buffer and then pulled q, k and v out ofit with three
cudaMemcpy2DAsyncper layer per chunk. The convolution already knows which channeleach thread owns, so it can address the destination directly.
causal_conv1d_silu_splitdoes that.Rebased onto
6e8b2e2aand squashed to one commit. This description replaces the original one. Thefour review points are answered below; the second one turned up a route-selection problem in the
shared part of this Op that the same measurement had to resolve.
Scope
This PR predates the issue-first rule in
CONTRIBUTING.md. Its problem and direction wereestablished in your review rather than in an Issue; if you would rather it be re-filed as an Issue
first, say so and I will close this and open one.
One decision: the convolution writes its three destinations instead of writing one plane that is
then copied three ways. Everything in the diff belongs to that decision, including the route
selection, which is the route table of this Op.
1. Compile-time geometry
CausalConvSplitOutput3now carries the partition in the type, followingw8_rowsplit_output.cuh:The struct holds only pointers; the boundaries and the three leading dimensions are constants inside
column(). The pointers carry__restrict__to record what the split entry proves - though I shouldsay that it buys nothing today:
column()hands back a plain pointer, and the SASS is identicalinstruction for instruction with and without the qualifier.
CausalConvContiguousOutputkeeps aplain pointer and a runtime leading dimension: one destination admits any row count, and the packed
entries do not prove their destination disjoint from
x.The wrapper resolves
(x.ne[0], out0.ne[0], out1.ne[0], out2.ne[0])to one of the two registeredprofiles and throws otherwise, so dispatch stays where
op-development.mdsection 4.1 puts it. Eachlauncher takes the resolved tag, asserts that the geometry covers the channel extent it was handed,
and instantiates one kernel.
Two narrowings follow, both stated in
include/ninfer/ops/causal_conv1d_silu.h. The partitionparity the paired route used to test at runtime is a
static_assert. And the entry requiresfour-byte aligned operands, which deletes the scalar split instantiation; the requirement is stated
for the entry rather than per route so a caller need not know which route its column count selects.
That alignment narrowing goes past what the review asked for - it buys one instantiation and one
host branch, and it is unobservable to the caller, whose tensors come off a 256-byte-aligned arena.
2. Route selection, measured
Both registered geometries,
T = 1..72, cold L2, one process, median of 40:On
T == 1the answer is no. On this commit, cold: the split entry measures 4.13 us, thededicated decode kernel 4.10, and a same-byte device copy of the same bytes 4.10. All three sit at
the launch floor of this harness, so there is nothing for a split decode route to recover, and none
was added.
The interval above the small-T bound was the finding. The
sequenceroute, which both entriesselected for
17 <= T <= 64, measured 12.29 us atT = 17and 28.67 us atT = 64, while theprefillroute it handed over to atT = 65measured 8.19 us for strictly more work. Separately,the small-T kernel had never been measured above its bound of 16, although it launches to 32
(
kCausalConvChannelTilex T threads, capped at 1024). It does step there - its block is 32 x T, soit holds two CTAs per SM to
T = 24and one above - but even at its ceiling it stays under theprefill route.
So the route table is now small-T to 32 and prefill above, and the sequence kernel, its launcher,
its declaration and
kCausalConvSequenceMaxTokensare removed rather than bypassed - its only callsites were the two entries above, and nothing else in the tree used them.
C = 10240is the same shape. Cold medians in this region land on a roughly 2 us grid, so read thesmall-T numbers as ~4.1 and ~6.1 and the prefill ones as ~8.2; the route finding is three to ten grid
steps and survives that, but single-step differences within one route are not signal. The remaining
seam is
32 -> 33, ~6.1 to ~8.2 us, and it is the small-T kernel's structural limit rather than atuning choice: at
T = 33its block would need 32 x 33 = 1056 threads.I took the removal as part of this decision rather than a separate PR because it is the route table
of the same Op and the measurement is the one point 2 asked for. Two consequences reach entries this
PR was not otherwise touching, and neither changes output. The packed entries' dispatch changes the
same way. And moving the small-T bound also moves the snapshot entry's, which reads the same
constant:
Tin 17..32 now takescausal_conv1d_snapshot_smallt_kernelinstead ofcausal_conv1d_sequence_snapshot_kernel. Measured on that entry, cold,C = 8192: 6.11 us atT = 8, 16, 17 and 24, and 8.16 at 32 - flat across the bound that moved. If you would rather havethe removal separately, I will split it out.
3. The stage, not half of it
--legacy-stagetimes what the stage was - one packed convolution into a[C,T]plane, then threeextract_bf16_columns- against the split entry, in one process under one set of timing conditions:Speedup of the split entry over that stage, cold, by column count:
The 10240 band is not homogeneous: its worst point is
T = 16at x1.68, because that geometry isalready at ~6.1 us from
T = 15where 8192 is still at ~4.1.Absolute,
C = 8192, cold: 10.27 -> 4.10 us atT = 16, 53.22 -> 30.75 at 1024, 360.48 -> 206.85 at8192. Across the whole ladder on both geometries the range is x1.56 to x2.50.
So "the split convolution pays 3.2-5.0%" was measured against the packed convolution alone; against
the stage it replaces it is faster everywhere. The
--legacy-stagearm is the decision benchmark youallowed; it is here so you can reproduce the comparison, and I will drop it with the
scatter.hinclude on request.
4. Documents, names, state rule
The two model documents are reverted to their
mastertext: the mathematics did not change, andthey should not carry a function name or a materialization decision.
The Op parameters are
out0/out1/out2. For the record, no public header ininclude/ninfer/ops/uses ordinal names today -
attn_input_proj.handgdn_input_proj.hname destinations by role - butthose Ops are themselves about q/k/v and this one is a convolution, so I read your point as applying
here specifically.
The state rule is enforced, on both distinct-state entries rather than only the new one:
The packed entry had no overlap validation at all, and
causal_conv1d_decode_launchselectscausal_conv1d_decode_distinct_kernelwhenever the two pointers differ - a kernel that declares bothstate pointers
__restrict__. A partially overlapping pair was therefore undefined behaviour there.The packed entry now also enforces the non-overlap the family contract states for
outandconv_state_out, which the route change made load-bearing: the prefill route publishes state in asecond launch that reads
x, so an out-of-contractout == xcall that the removed sequence kernelhappened to tolerate would otherwise corrupt state silently.
Resources
Workspace peak, read from the engine's own report, 35B-A3B, same prompt on both arms:
Exactly
convolution_dim * chunk * 2in each case - the buffer that no longer exists. No graph nodeappears or disappears; prefill is not captured (
src/core/decode_graph.cppis the only capturesite). Per-kernel resources, from
cuobjdump --dump-resource-usageon the two binaries: the splitsmall-T instantiations match the packed one at 38 registers and 1472 bytes of shared memory, and the
split prefill-pairs instantiations at 38 registers and no shared memory. Templating the packed
prefill kernel on its output map cost it two registers, 28 to 30.
After this change nothing under
src/calls either packedcausal_conv1d_siluoverload orextract_bf16_columns; their remaining uses are the tests,bench/ops/gdn_layer_bench.cu, whichcomposes the unfused snapshot alternative, and the
--legacy-stagearm this PR adds. I have notdeleted them - that is a separate decision and I would rather you make it.
Correctness
92 pass, 1 skipped (
27b_load_plan, no matching artifact on this box), 1 fails.ninfer_qwen3_6_27b_prefix_real_testfails withHost checkpoint restore changed greedy output: restored=64,1248, baseline=64,56127, restored_spec=0/0/0/1 baseline_spec=0/0/0/1 reused=305 transfers=1/1/3/3- identical on three consecutive runs of this commit. Unmodifiedmasterrebuilthere fails with the same line, character for character; that comparison was run on
9dbc0740and6e8b2e2aduring earlier work rather than on this commit, which changes nothing it touches. Botharms of the test's own comparison run in one process on one artifact, so it is not an artifact or an
environment effect. I will report it separately.
The Op suite covers both registered geometries at
T = 1, 2, 7, 15, 16, 17, 32, 63, 64, 65, 257, 1024, the exact-alias state form on both atT = 1, 2, 15, 16, 17, 32, 33, 64, 65, 257,destinations offset by one pair
(four-byte aligned, which the contract admits, but not the 256-byte alignment the arena gives), and
thirteen rejection classes: a null
x, an FP32 weight, a rank-3 destination, an unregistered rowprofile, the other geometry's profile on these channels, an FP32 destination, a null destination, a
short column count, two destinations in one buffer, a destination overlapping
x, a destinationoverlapping the input state, a destination that is not four-byte aligned, and a state pair that
overlaps without being the same storage.
End to end on 35B-A3B, three rounds, arms alternating inside each round, greedy: prefill +1.34%
at 8,515 tokens, +0.93% at 33,031, +0.76% at 33,031 with chunk 1024. All nine generations
byte-identical to master.
Checks not run
ncucounters:RmProfilingAdminOnlyis set on this host. Kernel-level claims come from the Opbenchmark and
cuobjdump, not hardware counters.through the same entry and the Op benchmark covers that geometry, but the end-to-end rows are 35B
only.
corpus.
RTX 5090, sm_120a, driver 580.105.08, CUDA 13.1.115, Release,
-DCMAKE_CUDA_ARCHITECTURES=120a.Base is
6e8b2e2a.