[Model] Enable ConvNeXt V2 with batch > 1 (issue #255)#295
Open
YWHyuk wants to merge 4 commits into
Open
Conversation
ConvNextV2 with batch > 1 died in codegen with "Index names length
mismatch: 2 != 3". The scheduler had approved fusing a reduction epilogue
into a GEMM whose 2-D (M rows x N cols) frame cannot express it, and
set_ranges then found more loop ranges than the template's coordinate map
has entries.
It approved it because the eligibility check read the reduction's stride
out of the node's repr:
stride = [i.strip()[:-1].split(",")[-1].strip()
for i in str(node).split("\n") if "r0" in i][1]
The intent (reject a reduction over the contiguous, stride-1 axis, which
the GEMM reduction template cannot codegen) was right, but picking the
second line that mentions "r0" lands on the wrong index expression once
the node has more than two dimensions -- so ConvNextV2's channels-first
LayerNorm mean was let through. Reading the stride off the LoopBody's
index expression instead makes it decline for the right reason. Note a
MemoryDep's index cannot be used here: it is normalized to a flat
contiguous access and no longer carries the per-axis strides.
Fixing that exposed the deeper problem: only a template knows its output
coordinate frame, yet can_fuse_horizontal hardcoded a case per template
kind and re-guessed that frame. It also mutated nodes (revert_group) from
inside a predicate the scheduler calls speculatively for every candidate
pair, carried a dead isinstance(MaxPoolTemplate) special case, and
declined without ever saying why.
So the decision moves to the templates, following how Inductor's own
CUTLASS and CPP backends do it:
- MLIRTemplate.try_fuse_epilogue / try_fuse_prologue own every condition
that depends on the frame -- including the config gates -- and return a
FusionPlan or None. They are pure; a node mutation the fusion needs is
deferred into the plan's remap and applied from MLIRScheduling.fuse(),
the commit hook Inductor calls once it really fuses. The plan is
recomputed there rather than cached, since can_fuse runs many times per
pair and node identities change as fusions land.
- The scheduler keeps only graph facts: which node carries the template,
the direction, and that this is a single-node to single-node fusion.
Case 1 (pointwise) and Case 2 (reduction) collapse into one delegation.
- Every decline logs a reason, like Inductor's WhyNoFuseNames. Declining
is a normal answer: upstream declines reduction epilogues outright.
- A template declares REDUCTION_EPILOGUE_ALIASING, the coordinate map its
reduction-epilogue codegen addresses. render() consumes it, and its
length is exactly what set_ranges asserts against -- so the fusion gate
is that assert, raised as a decline instead of a crash. Templates
without one cannot absorb a reduction epilogue, which retires the
support_reduction_fusion flag. GEMM's map has two entries, BMM's three.
When the rows do not fit, LoopBody.merge_loops() (which returns a new
body, so this stays pure) says whether merging them would; if it would,
the merge becomes the plan's remap.
tests/ops/fusion covers pointwise, reduction, prologue, conv and
attention fusion and is unchanged, as are the kernels each of them fuses.
Adds a matmul whose reduction is over the contiguous axis: it must not
fuse, and wrongly fusing it returns wrong values rather than failing to
compile.
ConvNextV2 batch > 1 no longer hits the assertion; it now stops at an
unrelated SPAD overflow, so issue #255's report is not fully closed.
SpadOverflowError was raised with no arguments and the only diagnostic was a logger.debug line that is off by default, so a failing compile said nothing beyond "SPAD overflow occurred." and gave no way to tell which kernel, which tiling, or by how much. load() already has the measured usage, the budget, the tile size and the kernel's origins in scope. Put them in the exception message.
Spad globals follow a fixed convention: the MLIR memref carries the full tile shape (all lanes), the .spad C header that Spike links declares the per-lane slice, and the gem5 header declares the full tile. codegen_spad_buffer() emits exactly that, tile_numel_per_lane and tile_numel_per_lane * vector_lane. index_expr() had the two headers swapped. The iota scratch buffer it allocates for vectorized index arithmetic declared compute_vec_size * vector_lane entries in the .spad header and compute_vec_size entries in the gem5 header. So the buffer Spike sees is vector_lane times too large, and the one gem5 sees is one element too small: the initializer stores two elements per iteration, so its last iteration writes one slot past compute_vec_size. The oversize side is what breaks compiles. Since the spad guard tightened to spad/2 for double buffering, a kernel with compute_vec_size 64 spends 64 KB of its 64 KB budget on an iota that only ever uses 64 entries. ConvNextV2 with batch > 1 fails there: its channels-first LayerNorm var_mean kernel needs 81984 bytes/lane, of which 65536 is the iota and only 16448 is real data. Declare compute_vec_size + 1 entries per lane, and vector_lane times that for gem5. The var_mean kernel now measures 16968 bytes/lane.
Add tests/models/test_convnextv2.py, a self-hosted CI job for it, and a model coverage row. batch=2 with depths=[1,1,1,1] and image_size=64 now matches CPU end to end, max abs diff 5.0e-06. The test keeps the runner on self-hosted like the other model tests: the depthwise 7x7 conv lowers to one gather kernel per tap, so the graph is wide.
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.
Fixes #255. ConvNeXt V2 with
batch > 1died at codegen withAssertionError: Index names length mismatch: 2 != 3. Two independent frontend bugs were stacked behind that message; the second only became visible once the first was fixed.batch=2now matches CPU end to end, max abs diff 5.0e-06.1. The fusion gate read the reduction stride out of a repr string
can_fuse_horizontaldecided whether a reduction epilogue could be folded into a GEMM by parsing the node'srepr():The intent was right: a reduction over the output's contiguous (stride-1) axis cannot be expressed in the GEMM template's 2-D reduction-epilogue frame, so it must not fuse. But taking the second line containing
"r0"lands on the wrong index expression once the node has more than two dimensions. ConvNeXt V2's channels-first LayerNorm mean is exactly that shape, so it was let through, andset_ranges(mlir_common.py:584) assertedlen(dim_aliasing) == len(ranges)->2 != 3.The fix: templates own their own fusion conditions
Rather than repair the string parse, the decision layer now follows the pattern both upstream Inductor backends use (
CUDACPPScheduling._can_fuse_epilogue_implfor CUTLASS,template_fusion_with_epilogues_supportedfor CPP):can_fusestays pure, expressibility is judged from the IR, and declining is cheap and logged.MLIRTemplate.try_fuse_epilogue(template_node, existing_epilogues, node_to_fuse)andtry_fuse_prologue(...)own every frame-dependent condition, config gates included, and returnFusionPlan | None. They are pure, so the scheduler can call them speculatively.FusionPlan.remap, a zero-arg thunk applied fromMLIRScheduling.fuse()-- the commit hookScheduler.fuse_nodes_oncealready calls._single_template_epilogue_pair,_single_prologue_template_pair) and delegates. It loses 155 lines.why_no_fuse()and logs a reason at DEBUG onPyTorchSimFrontend.mlir.mlir_template.The stride check now reads the real index expression via
LoopBody.get_all_read_expr(buffer_name)and the real reduction symbol viabody.vars[1]. Note thatMemoryDep.indexcannot be used here: it is normalized to a flat contiguous access and carries no per-axis strides, soindex.coeff(reduce_var)is always 0, which silently disables the check and produces wrong values rather than a crash. That is presumably why the original parsed the repr.REDUCTION_EPILOGUE_ALIASINGThe coordinate map that
render()already used is hoisted to a class attribute. Its length is exactly whatset_rangesasserts against, so the fusion gate is that assert, raised early as a decline instead of late as a crash.Nonemeans the template cannot absorb a reduction epilogue at all, which retires thesupport_reduction_fusionflag. GEMM declares 2 entries (row + reduced N), BMM declares 3 (batch + row + reduced N) -- that length is the only difference between them, so the conditions live in the base class andmlir_gemm_template.pyends up with no fusion logic at all.When the epilogue needs more loop ranges than the frame addresses, the template asks
body.merge_loops()(which is pure, unlikeSchedulerNode.merge_loops()) whether collapsing would make it fit; if so the merge becomes theplan.remap. For ConvNeXt V2 it correctly refuses -- the reduced C axis sits between batch and spatial in the channels-first layout, so the rows are not contiguous -- and the reduction runs as its own kernel.2. The index-expression scratch buffer was sized per tile, not per lane
With the LayerNorm reduction now correctly running as its own kernel, ConvNeXt V2 hit
SpadOverflowError.Spad globals follow a fixed convention, visible in
codegen_spad_buffer()(mlir_codegen_backend.py:1552-1553):memref.globaltile_desc.get_mlir_shape().spadC header (Spike links this)tile_size // vector_lanetile_sizeindex_expr()had the two headers swapped. The iota buffer ([0, 1, ..., compute_vec_size-1]) it allocates for vectorized index arithmetic declaredcompute_vec_size * vector_laneentries in the.spadheader andcompute_vec_sizein the gem5 header. So Spike's buffer isvector_lanetimes too large, and gem5's is one element too small -- the initializer stores two elements per iteration (ops.broadcast(init_iter, 2), kept wide to avoid scalar ops), so its last iteration writes one slot pastcompute_vec_size.Only
[0, compute_vec_size)is ever touched: oneaffine.parallel (%i) = (0) to (compute_vec_size)writes it, oneaffine.vector_load %buf[0] : vector<compute_vec_size x index>reads it.Since the spad guard tightened to
spad/2for double buffering, the oversize side is fatal. ConvNeXt V2'svar_meankernel:buf0..buf3(data tiles)buf4, buf5(mean/var out)index_expr_64_spad[8192]The real data is 16 KB; an iota that uses 64 entries (512 B) exhausts the whole budget. The heuristic mapping path has no tile-shrink fallback --
SpadOverflowErroris only caught insideautotune()-- so this is a hard failure.The fix declares
compute_vec_size + 1entries per lane andvector_lanetimes that for gem5. The+1is the two-wide initializer store, made explicit in a comment; it is the same slack idea asmax(tile_numel_per_lane, 2)incodegen_spad_buffer(). Thevar_meankernel now measures 16968 bytes/lane, and every otherindex_exprkernel shrinks by the same factor (index_expr_16_spad: 2048 entries -> 17).The MLIR
memreftype is left alone: it already carries the full-tile count, matching the convention, and the.lldeclares the symbolexternalso the C header is what defines the storage.3.
SpadOverflowErrornow says which kernel overflowedIt was raised with no arguments and the only diagnostic was an off-by-default
logger.debug, so a failing compile printedSPAD overflow occurred.and nothing else.load()already has the measured usage, the budget, the tile size and the kernel's origins in scope. That is how the kernel above was identified.4. Test, CI, coverage
tests/models/test_convnextv2.py(batch=2,depths=[1,1,1,1],image_size=64), a self-hosted CI job, and a README coverage row.Verification
Test Passed, max abs diff5.0068e-06.conv2d(x, w, b, padding=3, groups=96)), the smallest op that emitsindex_expr: functional mode, rebuilt binaries, max abs diff7.6e-06. This exercises the resized buffer end to end and rules out an alignment regression from the smaller allocation.tests/ops/fusion/:test_matmul_reduction,test_bmm_reduction,test_matmul_activation,test_conv_fusion,test_prologue_fusionall still compile with their expected fusedorigins(e.g.mm, max_1andbmm, max_1). Worth watching on review: BMM also supports reduction fusion, so putting the reduction conditions in the GEMM template alone silently splitstest_bmm_reductioninto two kernels.tests/ops/fusion/test_matmul_reduction.py::test_matmul_reduce_last_dim: a matmul whose reduction is over the contiguous axis must not fuse. Fusing it anyway returns wrong values rather than failing to compile, so the test checks numbers. Every pre-existing test reducesdim=-2(a non-contiguous axis), so this hole existed.The original ConvNeXt V2 crash has no self-contained repro --
matmul+permute+mean,+addmm, and a full channels-first LayerNorm all fail to reproduce it, because the trigger needs a node with more than two dims whose second"r0"line is the wrong one. Rather than fabricate one, the model test guards it.Follow-ups (not in this PR)
try_fuse_epiloguealready takesexisting_epilogues, so enabling it only needs the scheduler's single-node gate relaxed (upstream CUTLASS does this).support_epilogue_fusion/support_prologue_fusionremain as declarative gates inside the template layer.