feat(#672): vt::Conv1d and vt::ConvTranspose1d, with a CUDA provider that is BYTE-IDENTICAL to the host - #1115
Merged
Merged
Conversation
localai-bot
force-pushed
the
row/VT-CONVTRANSPOSE1D-CUDA
branch
4 times, most recently
from
August 17, 2026 11:08
1607886 to
03ac0a2
Compare
…that is BYTE-IDENTICAL to the host `vt` had NO transposed 1-D convolution of any kind, on any device. The two 1-D convolutions it did carry are `vt::CausalConv1dFwd` (causal, stateful, SiLU-folded) and `vt::DepthwiseConv1d` (centre-padded, depthwise), and neither can express a scatter that GROWS the time axis. `vt::Conv2d` and `vt::DepthwiseConv1d` are moreover registered CPU-only. So the stage that is 88.5% of MiniMax-Music3's acoustic-half profile had no device op to route to at all, and hand-rolling a kernel outside the shared seam is what AGENTS.md forbids. This adds both ops with a CPU provider and a CUDA provider, and routes the shared 1-D BigVGAN core through them. `vocoder1d` has six decoding consumers, verified by call-site survey rather than assumed: `minimax_music3_acoustic.cpp` (6 + 1), `minimax_h3_audio_vae.cpp` (7 + 1), `ltx2_audio_vae.cpp` (4 + 2), `bigvgan.cpp` (1 + 1), `minimax_music3_ar.cpp` and `indextts2_pipeline.cpp` (1 forward conv each). `bigvgan_loader.cpp` and `minimax_music3_loader.cpp` run no convolution -- they fold weight norm at load. A whole-src sweep found no other caller. This is a shared-seam change, not a Music3 private path. THE CPU PATH DID NOT MOVE, AND THAT IS PROVEN RATHER THAN ASSERTED. The two CPU kernels are the `vocoder1d` host loops as they stood at 8fa405b, carried into `src/vt/cpu/cpu_conv1d_general.cpp` statement for statement -- same f64 accumulator, same visit order, same bias seeding, same `value == 0.0` skip, same output-channel partition over the same threadpool, and the tensors are views over the caller's own `std::vector` rather than copies. `test_host_parallel` compares the shipped function against a VERBATIM copy of the pre-change loop at five thread counts and stays green. THE CUDA PROVIDER IS BYTE-IDENTICAL TO THE CPU ONE. Not within a tolerance -- the gate says `memcmp`, and none is claimed because none is needed. Both are one f64 accumulator per output element. For the forward conv that is trivial, because the host loop is already a gather. For the transposed one it is the whole design: fix a destination cell and ask which additions the host SCATTER lands in it and in what order, and the answer is `ic` ascending then input position `t` ascending, with at most one tap per `t`. A thread that owns that cell and sweeps in the same order performs the identical sequence of f64 additions. What is left is FMA contraction, and both sides are pinned against it: the host by the project-wide `-ffp-contract=off` (CMakeLists.txt:40-56) and the device kernel locally by `__dmul_rn` / `__dadd_rn`, because nvcc's flags are separate and its `-fmad` default is on. Two details in the transposed kernel are load-bearing rather than cosmetic. The `value == 0.0` skip is reproduced exactly, because dropping it changes the SIGN of a zero output cell -- (-0.0) + (+0.0) is +0.0 while -0.0 alone stays -0.0. And the bias is added LAST for the transposed op and FIRST for the forward one, matching each host loop respectively. The f64 accumulator is a DELIBERATE divergence from torch, which accumulates an f32 conv in f32. It is named here rather than inherited silently because f64 is what the host reference used and therefore what every committed golden for all four consumers was taken with. It costs nothing in bytes moved -- activations and weights stay f32 in memory, only the register width differs -- and narrowing it would re-gate four shipped models at once. `vt::DepthwiseConv1d` accumulates in f32 and its own byte-exactness gate pins THAT width, so these are siblings and it is untouched, exactly the call it made against `vt::CausalConv1dFwd`. Two things the tests had to earn rather than claim. First, an f64 accumulator stored through an f32 CANNOT see a reduction-order change on well-scaled data -- measured, not supposed -- which is why every equality claim is also exercised on engineered catastrophic cancellation with +2^40 / -2^40 taps. Second, the cancellation case asserts its OWN teeth: reversing the input-channel sweep must change the answer, and the check reports how many cells move. A weaker mutation was tried first and correctly read 0 -- swapping which channel carries the positive tap leaves the partial sums at the same magnitude at the same step, so it is not an order change at all. That is recorded in the test so it is not re-derived. MEASURED ON JETSON THOR, sm_110 (`kairos-4db2`, aarch64, driver 595.78, in `vllmcpp-thor:cuda13.0.1`, nvcc 13.0.88, `-DVLLM_CPP_CUDA_ARCHITECTURES=110`): `test_ops_conv1d_general` 8 cases / 385 assertions against 8 / 347 on the x86-64 CPU box -- the 38-assertion difference IS the CUDA-vs-CPU `memcmp` arm, which is how the run proves it executed rather than skipped. And the stronger leg, which was not planned: the consumer gates were run on BOTH arms and are identical (`test_host_parallel` 8/877, `test_vocoder1d` 10/58, `test_bigvgan` 6/65 with `VLLM_CPP_VOCODER_DEVICE=cuda`). `test_host_parallel`'s oracle is a verbatim in-test copy of the pre-op host loop and its comparison is bitwise, so a green there with the device selected says the CUDA kernel is byte-identical to the pre-change scalar host loop END TO END through the consumers' own entry point. SPEED IS VOID, and the reason is a lease I did not take. The GPU fleet is scheduled by `rc`; these runs went in by ssh + docker directly on the box, serialised by `flock ~/gpu.lock` -- the OLD mutex -- while the concurrent Music3 DiT session held the same box through `rc`. Two different mutexes, neither excluding the other, which is verbatim the failure `.agents/environment.md` already records for a GPU_LOCK naming the wrong path (#777), and almost certainly the 3x same-arm swing the samples show. It was not simply re-run under a lease because `rc run` executes inside the worker container, and thor's worker has no toolchain at all and does not mount the $HOME where the build tree lives. A valid re-measurement needs a worker image with the CUDA devel toolchain, or this build placed on the shared `/workspace`. Until then the speed axis has NO instrument. The void numbers are retained in the spec rather than deleted, because the failure is more instructive than they were: on their own terms the device arm did not win at any size, and the A/B was not accepted even before the lease defect was known. Spec 13.10 names the next steps, with taking the lease as step zero. STAGED, AND SAID SO. What is not reached is the DEFAULT: the device arm ships opt-in behind `VLLM_CPP_VOCODER_DEVICE=cuda` and `cpu` remains the default, so every consumer is byte-for-byte where it was. Flipping four shipped audio models onto a device arm needs its own re-gate against each one's goldens, and that is not a default the row that ADDED the arm is entitled to set. Per `.agents/reachability.md` "Landing a slice that is not reached yet": what is not reached is `ResolveConvDevice()`'s default; the row that owns the wiring is `MODEL-MUSIC-minimax-music3-mini-max-music3-for-conditional-generation`; the issue is #672. Also owed and named rather than hidden: the device arm allocates, uploads, downloads and frees per call, so device-resident weights, one persistent queue, and a chain that stays on the device between stages are all left on the table. THE KNOB NAMES NO DEVICE. A first draft spelled `kCUDA` in `vocoder1d.cpp` and `check-device-leakage.py` refused it, correctly -- that file is the device-agnostic shared layer. Asking the op/provider table instead turned out better than the narrower spelling: the knob now takes any device name `vt` knows and refuses one with no registered provider, so a Metal, Vulkan or ROCm provider becomes reachable by being registered and nothing else. The name-to-enum walk is a new `vt::DeviceTypeFromName` beside `DeviceTypeName` in include/vt/device.h, because enumerating the device list is the seam's job and a `static_cast<DeviceType>(i)` in the shared layer is the same leak wearing a different hat. It is gated by a round trip over every DeviceType, since the failure it can have is a missing or transposed entry that a spot check of the entries that ARE present cannot see. ONE CHECKER CHANGED, AND IT GOT STRONGER, with the argument attached to the diff that needs it. `tests/scripts/test_vocoder1d_single_home.py` went red with `Conv1d has 2 definitions`. It guards a FORK of the vocoder core -- a failure no numeric test can see, because a fresh copy agrees on the day it is made -- using a line-anchored TEXT match, which cannot see a namespace and so read `vt::Conv1d`'s definition in `src/vt/ops.cpp` as a second copy of `vocoder1d::Conv1d`. It is the opposite of a second copy: it is the op the core now delegates to. The exclusion of `src/vt/` was PRICED rather than simply taken, because excluding a tree from a guard is how guards die. Two assertions were added beside it: (1) the core must still CALL `vt::Conv1d` / `vt::ConvTranspose1d`, without which the count would read a happy `1` while `vocoder1d.cpp` quietly re-grew its own loops and all six consumers left the shared seam with every numeric gate still green; and (2) the file walk must report how many files it examined, without which a scan narrowed to `vocoder1d.cpp` alone would report every count as 1 and pass while seeing none of the tree. Mutated, not argued (scratch copy, restored byte-for-byte). RED-BEFORE: the unrepaired checker on this tree gives the exact CI failure. GREEN-AFTER: 6 tests OK. M1 removing the delegation FAILS on the new assertion while the count still reads 1. M2 adding a genuine fork to `bigvgan.cpp` still FAILS on the original invariant. M3 narrowing the walk FAILS on the new count-of-files assertion. The first mutation attempt was itself broken -- no `.git` in the scratch copy, so `git ls-files` failed and the suite reported ERRORS rather than a failure, an infra fault presenting as a code verdict -- which is why a control run of the unmutated copy is part of the evidence. Recorded in the spec at 13.9. The obvious follow-on -- extending the same machinery to the existing CPU-only `vt::Conv2d` / `vt::DepthwiseConv1d` -- was ASSESSED and declined here, with the survey filed as #1114 rather than half-done. The reason is not kernel difficulty: of the seven models named as stuck behind those ops, exactly ONE (`parakeet_encoder.cpp`) calls either, the other six run their own host loops and three of those are 3-D convolutions the 2-D op cannot express. No caller passes device tensors either, so a provider landed today would be reached by its own test and nothing else. Gates, with assertion counts because `assertions: 0` is a skip wearing a pass: test_ops_conv1d_general 8/347 x86 and 8/385 Thor (new), test_host_parallel 8/877, test_vocoder1d 10/58, test_bigvgan 6/65, test_backend 8/48, test_op_provider 12/412, test_minimax_h3 79/57395, test_ltx2_vae 42/3120, test_indextts2_family 7/22 -- the last four with `CHECKPOINT_ROOT=/mnt/nas_share/checkpoints` set, which several of them need or they silently skip. Issue: #672 FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: ClaudeCode:claude-opus-5 [ClaudeCode]
localai-bot
force-pushed
the
row/VT-CONVTRANSPOSE1D-CUDA
branch
from
August 17, 2026 11:17
03ac0a2 to
616699a
Compare
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.
vthad no transposed 1-D convolution of any kind, on any device. The two 1-D convolutions it carried arevt::CausalConv1dFwd(causal, stateful, SiLU-folded) andvt::DepthwiseConv1d(centre-padded, depthwise), and neither can express a scatter that GROWS the time axis.vt::Conv2dandvt::DepthwiseConv1dare moreover registered CPU-only. So the stage that is 88.5 % of MiniMax-Music3's acoustic-half profile had no device op to route to at all, and hand-rolling a kernel outside the shared seam is whatAGENTS.mdforbids.This adds
vt::Conv1dandvt::ConvTranspose1dwith a CPU provider and a CUDA provider, and routes the shared 1-D BigVGAN core through them.What reaches the op
Verified by call-site survey, not assumed.
vocoder1dis the shared core, so routing it routes everything that decodes through it:Conv1dsitesConvTranspose1dminimax_music3_acoustic.cppminimax_h3_audio_vae.cppltx2_audio_vae.cppbigvgan.cppminimax_music3_ar.cppindextts2_pipeline.cppbigvgan_loader.cpp,minimax_music3_loader.cppA whole-
src/sweep found no other caller. One gap is named rather than left to be discovered:ltx2_audio_vae.cpp:75carries its own 2-D host convolution loop behind no op at all; out of scope here and filed with the rest of the unrouted surface as #1114.The CPU path did not move, and it is proved
The CPU kernels are the
vocoder1dhost loops as they stood at8fa405bb7, carried intosrc/vt/cpu/cpu_conv1d_general.cppstatement for statement — same f64 accumulator, same visit order, same bias seeding, samevalue == 0.0skip, same output-channel partition over the same threadpool, and the tensors are VIEWS over the caller's ownstd::vectorrather than copies.test_host_parallelcompares the shipped function against a verbatim copy of the pre-change loop at five thread counts, bitwise. This PR also adds the transposed op's missing catastrophic-cancellation case, which §12 had forLinearNoBiasandConv1dbut not forConvTranspose1d.The CUDA provider is BYTE-IDENTICAL — no tolerance is claimed
The row was scoped expecting a tolerance to justify against a measured control. None is needed.
Both providers are one f64 accumulator per output element. For
Conv1dthat is trivial — the host loop is already a gather. ForConvTranspose1dit is the whole design: the host loop is a SCATTER, so fix a destination cellpand ask which additions land in it and in what order. The answer isicascending, then input positiontascending, and for eachtat most ONE tapk(the one witht*stride + k*dilation == p). A thread that ownspand sweeps in that order performs the identical sequence of f64 additions into the identical accumulator.Two details are load-bearing rather than cosmetic:
value == 0.0skip is reproduced exactly, because dropping it changes the sign of a zero output cell —(-0.0) + (+0.0) == +0.0while-0.0alone stays-0.0;That leaves one way the arms could disagree: FMA contraction. Both sides are pinned. The host by the project-wide
-ffp-contract=off(CMakeLists.txt:40-56, added for exactly this class of bug); the device kernel locally by__dmul_rn/__dadd_rn, because nvcc's flags are separate and its-fmaddefault is on. So the gate assertsmemcmpequality.The f64 accumulator is a deliberate divergence from torch
torch accumulates an f32 conv in f32. These do not, because f64 is what the host reference used and therefore what every committed golden for all four consumers was taken with. Named here rather than inherited silently, per
.agents/porting.md"Mirror the memory format" — a WIDER accumulator is exactly the class of divergence a token gate cannot see. It costs nothing in bytes moved: activations and weights stay f32 in memory and only the register width differs.vt::DepthwiseConv1daccumulates in f32 and its own byte-exactness gate pins that width, so these are siblings and it is untouched — the same call that op made againstvt::CausalConv1dFwd.The gate had to earn its teeth, twice
An f64 accumulator stored through an f32 cannot see a reduction-order change on well-scaled data. That is measured, not supposed. So every equality claim is also exercised on engineered catastrophic cancellation (
+2^40/-2^40taps through a shared weight row).And the cancellation case asserts its own teeth: reversing the input-channel sweep must change the answer, and the check reports how many cells move. A weaker mutation was tried first and correctly read 0 — swapping which channel carries the positive tap leaves the partial sums at the same magnitude at the same step, so it is not an order change at all. Recorded in the test so it is not re-derived.
Gates
Assertion counts included because
assertions: 0is a skip wearing a pass. The last four needCHECKPOINT_ROOT=/mnt/nas_share/checkpointsor they silently skip.test_ops_conv1d_general(new)test_host_paralleltest_vocoder1dtest_bigvgantest_minimax_h3test_ltx2_vaetest_indextts2_familytest_op_providerThe stronger leg, which was not planned
The consumer gates were run twice on Thor, once on each arm, and they are identical:
VLLM_CPP_VOCODER_DEVICE=cpu=cudatest_host_paralleltest_vocoder1dtest_bigvgantest_host_parallelis not an ordinary suite to pass on a device arm: its oracle is a verbatim in-test copy of the pre-op host loop and its comparison is bitwise. A green there with the device selected says the CUDA kernel is byte-identical to the pre-change scalar host loop end to end through the consumers' own entry point, at every shape it carries including the engineered cancellation cases — not merely at the op boundary.Two things it does not say, so the leg is not over-read: its thread-count sweep is redundant on the device arm (the host pool is unused there), and these are three suites, not the four consumers' full golden sets.
test_minimax_h3(79 / 57,395) andtest_ltx2_vae(42 / 3,120) were run on the CPU arm only, and re-gating them with the device selected is exactly the work the default flip waits on.Measurements taken on
b25a7ebf6;git diffagainst HEAD is empty for both kernels,ops.cpp,include/vt/ops.hand both gate files. A same-SHA re-run is queued.The 347 → 385 difference IS the CUDA-vs-CPU
memcmparm, which is how the Thor run proves it executed rather than skipped (both[SKIP]lines are absent from its output). Measured on Jetson Thorkairos-4db2, aarch64, sm_110, driver 595.78, invllmcpp-thor:cuda13.0.1(nvcc 13.0.88), built-DVLLM_CPP_CUDA=ON -DVLLM_CPP_CUDA_ARCHITECTURES=110 -DVLLM_CPP_TRITON=OFF.Full local build exit 0;
scripts/agent-preflight.sh --no-require-rolereports All gates green.One checker changed, and it got stronger
tests/scripts/test_vocoder1d_single_home.pywent red withConv1d has 2 definitions. It guards a FORK of the vocoder core — a failure no numeric test can see, because a fresh copy agrees on the day it is made — using a line-anchored TEXT match, which cannot see a namespace and so readvt::Conv1d's definition insrc/vt/ops.cppas a second copy ofvocoder1d::Conv1d. It is the opposite of a second copy: it is the op the core now delegates to.Excluding
src/vt/was priced, not simply taken, because excluding a tree from a guard is how guards die. Two assertions were added beside it:vt::Conv1d/vt::ConvTranspose1d— without it the count reads a happy1whilevocoder1d.cppquietly re-grows its own loops and all six consumers leave the shared seam, with every numeric gate still green;vocoder1d.cppalone reports every count as 1 and passes while seeing none of the tree.Mutated, not argued (scratch copy, restored byte-for-byte):
AssertionError: 2 != 1 : Conv1d has 2 definitions— the exact CI failurebigvgan.cppThe first mutation attempt was itself broken — no
.gitin the scratch copy, sogit ls-filesfailed and the suite reported ERRORS rather than a failure, an infra fault presenting as a code verdict. That is why a control run of the unmutated copy is part of the evidence. Recorded in the spec at §13.9.Staged, and said so
What is not reached is the DEFAULT. The device arm ships opt-in behind
VLLM_CPP_VOCODER_DEVICE=cuda;cpuremains the default, so every consumer above is byte-for-byte where it was. Flipping four shipped audio models onto a device arm needs its own re-gate against each one's goldens, and that is not a default the row that ADDED the arm is entitled to set.Per
.agents/reachability.md"Landing a slice that is not reached yet", the three things it asks for: what is not reached isResolveConvDevice()'s default resolution; the row that owns the wiring isMODEL-MUSIC-minimax-music3-mini-max-music3-for-conditional-generation; the issue is #672. Listed in the spec's owed table.Also owed and named rather than hidden: the device arm allocates, uploads, downloads and frees per call, with a queue per call. That is deliberately literal —
cudameans cuda, with no size threshold quietly sending small shapes back to the host, because a threshold would make the consumer gates report on a state they were not given. Device-resident weights, one persistent queue, and a chain that stays on the device between stages are all left on the table.Speed: VOID — the timings were taken outside the fleet lease
Every timing here is void, and the reason is more useful than the numbers were.
The GPU fleet is scheduled by
rc. These runs went in byssh+docker rundirectly on the box, serialised byflock ~/gpu.lock— the old mutex — while the concurrent MiniMax-Music3 DiT session held the same box throughrc. Two different mutexes, neither excluding the other: verbatim the failure.agents/environment.mdalready records for aGPU_LOCKnaming the wrong path ("flocksucceeds on it, so the run is unserialised and only looks like someone else misbehaving. That cost a whole Marlin series, #777"). That is almost certainly the 3x swing below — a defect in how the samples were taken, not a fact about the kernel.It was not simply re-run under a lease because
rc runexecutes inside the worker's container, and thor's worker has no toolchain at all (no gcc / g++ / cmake / ninja / nvcc / make, probed) and does not mount the box's$HOMEwhere the build tree lives; its/workspaceis the shared NAS. A valid re-measurement needs a worker image carrying the CUDA devel toolchain, or this build placed on/workspaceby something that has one. Until then the speed axis has no instrument, and that is step zero of the open gap, not a caveat.dgx:gpu0is also UP and schedulable (GB10, unified memory) — the "dgx.casa is down" note this row was briefed with was stale — so a second, materially different device is available for the re-measurement.The void numbers, retained rather than deleted
Two things, and they must not be collapsed. Measured on Thor sm_110, idle box (
uptime4.54 before / 4.57 after, 0 other users), same binary,VLLM_CPP_VOCODER_DEVICEthe only variable, best-of-3, three interleaved reps, via the newvocoder-conv-ab:And that A/B is not accepted, because a sweep taken minutes later on the same box and binary put the CPU chain at frames=96 at 0.2280 s against 0.0765 s — 3x, same arm, same workload — while CUDA read 0.2000 s in both:
The device arm is stable to four digits across runs; the HOST arm is the untrustworthy instrument here. So no ratio is claimed. What survives is the weaker and defensible statement: at no measured size did the device arm win, and at the largest, most compute-dominated point it was 1.18x slower.
A hypothesis, labelled as one. The per-stage ratios are flat (0.37–0.40x across a 150x span of work), which is the signature of a compute-RATE difference rather than per-call staging, since fixed overhead would punish the smallest stage most. The candidate is the f64 accumulator — consumer/Jetson Blackwell runs fp64 at a fraction of its fp32 rate — and f64 is not optional here: it is what makes the arms byte-identical and what four models' goldens were taken with. Nothing has read a counter:
nsysin that image is 2024.2.3 and cannot trace CUDA on that box.Open gap, not a ceiling. Next steps, in order: take the lease (nothing above is admissible until the arms are measured under
rc); get an instrument (newernsys/ncuon Thor); remove the per-call staging (§13.6's owed list — the flat ratio argues it is not the dominant term, which is why it should be measured rather than assumed); an f32-accumulate device variant, which is the lever if the fp64 hypothesis holds and which is expensive in the right way because it is not byte-identical and cannot inherit this row'smemcmp; and a GPU whose fp64 is not 1/64 (Thor is the only one here, dgx.casa is down).This does not touch the correctness result, which is what the PR turns on. The device arm shipping OFF by default was already right for numerics reasons; this says it would also have been right for speed.
The
Conv2d/DepthwiseConv1ddevice arms — assessed, and declined hereThe obvious follow-on was to extend the same machinery to the two existing CPU-only conv ops. The survey says do not, and the reason is not kernel difficulty: a CUDA provider for them would be dead on arrival. Of the seven models named as stuck behind them, exactly ONE (
parakeet_encoder.cpp:166,194) calls either; the other six run their own host loops, and three of those are 3-D convolutions the 2-D op cannot express.muse_glimmer_vision.cpphas no convolution at all — it is already onvt::MatmulBT. No caller passes device tensors either, so a provider landed today would be reached by its own test and nothing else.Filed with the full evidence, including why the 27 gated dtype combinations and the f32 accumulator make the kernel bodies non-shared: #1114.
Issue: #672
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: ClaudeCode:claude-opus-5 [ClaudeCode]