Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b0fd2af
docs(dspark): storage refuted; ratio stable at ~0.966 over three sess…
mudler Aug 12, 2026
73088f0
merge: origin/main into row/SPEC-DSPARK-LOADER
mudler Aug 13, 2026
b87b6a5
perf(dspark): unblock upstream ncu, and REFUTE the DRAM-bound attribu…
mudler Aug 13, 2026
44c0a48
fix(record): retract the "latency-bound" reading -- 6z's DRAM attribu…
mudler Aug 13, 2026
e60efef
perf(dspark): the Marlin kernel is NOT the gap -- 6x's localisation R…
mudler Aug 13, 2026
cabaf17
perf(dspark): the in-situ denominator was WRONG -- experts, not block…
mudler Aug 13, 2026
115b630
fix(record): ours touches FEWER experts, not more -- 6af's inference …
mudler Aug 13, 2026
b0fc9d8
fix(record): the GEMM mix, and the MODE HOLE under every in-situ rati…
mudler Aug 13, 2026
1fa4c2d
perf(dspark): blocks_per_sm is a DEAD lever, and the box cannot curre…
mudler Aug 13, 2026
8c74dd1
merge: origin/main into row/SPEC-DSPARK-MARLIN-NCU
mudler Aug 13, 2026
69e9e7a
fix(record): the standalone Marlin runs were taken UNLOCKED -- wrong …
mudler Aug 13, 2026
d7d67be
fix(record): review FAIL repairs -- per-expert cost is NOT flat, and …
mudler Aug 13, 2026
571102c
merge: origin/main into row/SPEC-DSPARK-MARLIN-NCU
mudler Aug 13, 2026
ae6a8d9
perf(dspark): the first fully-controlled paired run measures 0.9889, …
mudler Aug 13, 2026
84ee11d
merge: origin/main into row/SPEC-DSPARK-MARLIN-NCU
mudler Aug 13, 2026
258f501
record(dspark): the n=2 repeat FAILS its own drift gate -- ~0.98 on O…
mudler Aug 13, 2026
4cfb1e2
merge: origin/main into row/SPEC-DSPARK-MARLIN-NCU
mudler Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
422 changes: 422 additions & 0 deletions .agents/benchmark-record.md

Large diffs are not rendered by default.

357 changes: 357 additions & 0 deletions .agents/specs/dspark-spec-decode.md

Large diffs are not rendered by default.

213 changes: 213 additions & 0 deletions benchmarks/marlin_moe_standalone.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// OUR arm of the #442 standalone Marlin harness.
//
// Mirrors scripts/marlin-moe-standalone.py exactly: same 35B-A3B decode shapes
// (hidden 2048, moe_intermediate 512, E=256, top_k=8, moe_block_size 8), same
// gate_up GEMM, same expert-pool control over the occupied block count. Prints
// us/call and us/block so our plateau can be laid against upstream's 5.2-5.5.
//
// Not a test: no assertions, no goldens. It measures the kernel only.
//
// RUN IT UNDER THE BOX LOCK: `flock $HOME/gpu.lock ...`, NOT /tmp/gpu.lock,
// which coordinates with nothing. `nvidia-smi` showing no compute apps does
// not mean the GPU is unreserved, so check `fuser -v $HOME/gpu.lock` first.
// Absolute timings taken unlocked are upper bounds; only interleaved RATIOS
// survive contention.
//
// NOT WIRED INTO ANY BUILD TARGET (#442). Nothing compiles this file, so it
// carries no -Werror and no CI, and it will rot against
// vt::MoeGroupedGemmNvfp4Marlin's signature. The recorded measurements were
// taken from an out-of-tree build. Wiring it into examples/CMakeLists.txt the
// way benchmarks/vulkan_gemm_ab.cpp is wired is owed.
//
// Its routing RNG is a DIFFERENT stream from the python arm's, so the two
// arms occupy different block counts at the same --experts pool. Comparisons
// between them are NORMALISED by blocks, not matched on them; neither arm can
// yet take an externally supplied routing tensor.

#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <random>
#include <string>
#include <vector>

#include "vt/backend.h"
#include "vt/cuda/marlin_repack.h"
#include "vt/dtype.h"
#include "vt/ops.h"

namespace {

using vt::Backend;
using vt::Device;
using vt::DeviceType;
using vt::DType;
using vt::Queue;
using vt::Tensor;

Device Gpu() { return Device{DeviceType::kCUDA, 0}; }

Tensor MakeT(void* data, DType dt, Device dev, const std::vector<int64_t>& shape) {
Tensor t;
t.data = data;
t.dtype = dt;
t.device = dev;
t.rank = static_cast<int>(shape.size());
int64_t stride = 1;
for (int i = t.rank - 1; i >= 0; --i) {
t.shape[i] = shape[static_cast<size_t>(i)];
t.stride[i] = stride;
stride *= shape[static_cast<size_t>(i)];
}
return t;
}

class Dev {
public:
Dev(Backend& b, Queue& q, DType dt, const std::vector<int64_t>& shape,
const void* host = nullptr)
: b_(b) {
int64_t numel = 1;
for (auto s : shape) numel *= s;
bytes_ = static_cast<size_t>(numel) * vt::SizeOf(dt);
p_ = b_.Alloc(bytes_ == 0 ? 1 : bytes_);
if (host != nullptr) b_.Copy(q, p_, host, bytes_);
t_ = MakeT(p_, dt, Gpu(), shape);
}
~Dev() { b_.Free(p_); }
Dev(const Dev&) = delete;
Dev& operator=(const Dev&) = delete;
Tensor& tensor() { return t_; }
void* ptr() { return p_; }

private:
Backend& b_;
void* p_ = nullptr;
size_t bytes_ = 0;
Tensor t_;
};

int IntArg(int argc, char** argv, const char* name, int fallback) {
for (int i = 1; i + 1 < argc; ++i)
if (std::strcmp(argv[i], name) == 0) return std::atoi(argv[i + 1]);
return fallback;
}

} // namespace

int main(int argc, char** argv) {
const int pool_arg = IntArg(argc, argv, "--experts", 0);
const int iters = IntArg(argc, argv, "--iters", 80);
const int warmup = IntArg(argc, argv, "--warmup", 20);
const int M = IntArg(argc, argv, "--m", 9);
const int zero_ws = IntArg(argc, argv, "--zero-ws", 1);

const int E = 256, K = 2048, N = 512, top_k = 8;
const int pool = pool_arg > 0 ? pool_arg : E;
const int size_n = 2 * N; // gate_up
const int size_k = K;

Backend& b = vt::GetBackend(DeviceType::kCUDA);
Queue q{Gpu(), nullptr};
void* stream = nullptr;
const int dev_id = 0;

// Weights: random packed nibbles, repacked per expert into Marlin layout.
// Marlin's runtime is data independent, so random bits time like real ones.
std::mt19937 rng(1234);
const size_t raw_bytes = static_cast<size_t>(size_n) * size_k / 2;
std::vector<uint8_t> raw(raw_bytes);
for (auto& x : raw) x = static_cast<uint8_t>(rng() & 0xFF);
const size_t scale_bytes = static_cast<size_t>(size_n) * size_k / 16;
std::vector<uint8_t> raw_s(scale_bytes);
for (auto& x : raw_s) x = 0x38; // fp8-e4m3 ~ 0.5, safely positive

Dev staging(b, q, DType::kI8, {size_n, size_k / 2}, raw.data());
Dev staging_s(b, q, DType::kI8, {size_n, size_k / 16}, raw_s.data());

Dev wq(b, q, DType::kI32, {E, size_k / 16, size_n * 2});
Dev sc(b, q, DType::kI8, {E, size_k / 16, size_n});
const float sf = 1.0f;
std::vector<float> gs(static_cast<size_t>(E),
vt::cuda::MarlinNvfp4ProcessGlobalScale(1.0f, sf));

const size_t wq_expert_words = static_cast<size_t>(size_n) * size_k / 2 / 4;
for (int e = 0; e < E; ++e) {
vt::cuda::MarlinRepackExpertWeight(
stream, dev_id,
static_cast<uint32_t*>(wq.ptr()) + static_cast<size_t>(e) * wq_expert_words,
static_cast<const uint8_t*>(staging.ptr()), size_k, size_n);
vt::cuda::MarlinProcessExpertScales(
stream, static_cast<const uint8_t*>(staging_s.ptr()),
static_cast<uint8_t*>(sc.ptr()) + static_cast<size_t>(e) * scale_bytes,
size_k, size_n, sf);
}
b.Synchronize(q);
Dev dgs(b, q, DType::kF32, {E}, gs.data());

// Routing, drawn from `pool` distinct experts -- the block-count control.
const int P = M * top_k;
std::vector<int32_t> topk_ids(static_cast<size_t>(P));
std::vector<float> topk_w(static_cast<size_t>(P), 1.0f);
for (int i = 0; i < P; ++i)
topk_ids[static_cast<size_t>(i)] = static_cast<int32_t>(rng() % static_cast<unsigned>(pool));

const int block = vt::cuda::MarlinMoeAlignBlockSizeSelect(M, top_k, E);
int max_tok = 0, max_blk = 0;
vt::cuda::MarlinMoeAlignSizes(M, top_k, E, block, &max_tok, &max_blk);
Dev dtid(b, q, DType::kI32, {M, top_k}, topk_ids.data());
Dev dtw(b, q, DType::kF32, {M, top_k}, topk_w.data());
Dev sorted_ids(b, q, DType::kI32, {max_tok});
Dev expert_ids(b, q, DType::kI32, {max_blk});
Dev num_pad(b, q, DType::kI32, {1});
vt::cuda::MarlinMoeAlignBlockSize(stream, static_cast<const int32_t*>(dtid.ptr()), M,
top_k, E, block,
static_cast<int32_t*>(sorted_ids.ptr()),
static_cast<int32_t*>(expert_ids.ptr()),
static_cast<int32_t*>(num_pad.ptr()));
b.Synchronize(q);
int32_t past = 0;
b.Copy(q, &past, num_pad.ptr(), sizeof(int32_t));
b.Synchronize(q);

const int sms = vt::cuda::MarlinDeviceSms(dev_id);
Dev ws(b, q, DType::kI32, {sms * 4});
Dev dact(b, q, DType::kBF16, {M, K});
Dev dout(b, q, DType::kBF16, {P, size_n});

vt::MoeMarlinArgs args{};
args.moe_block_size = block;
args.top_k = top_k;
args.size_m = M;
args.size_n = size_n;
args.size_k = size_k;
args.mul_topk_weights = false;

// vLLM's arm does NOT re-zero the workspace per call (the kernel leaves it
// reset), so timing ours WITH a per-call memset adds a launch upstream never
// pays. --zero-ws 0 removes that asymmetry.
b.Memset(q, ws.ptr(), 0, static_cast<size_t>(sms) * 4 * sizeof(int32_t));
auto once = [&]() {
if (zero_ws) b.Memset(q, ws.ptr(), 0, static_cast<size_t>(sms) * 4 * sizeof(int32_t));
vt::MoeGroupedGemmNvfp4Marlin(q, dout.tensor(), dact.tensor(), wq.tensor(),
sc.tensor(), dgs.tensor(), ws.tensor(),
sorted_ids.tensor(), expert_ids.tensor(),
num_pad.tensor(), dtw.tensor(), args);
};

for (int i = 0; i < warmup; ++i) once();
b.Synchronize(q);
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < iters; ++i) once();
b.Synchronize(q);
const auto t1 = std::chrono::steady_clock::now();

const double us =
std::chrono::duration<double, std::micro>(t1 - t0).count() / iters;
const int blocks = past / block;
std::printf("OURS gate_up M=%d pool=%d zero_ws=%d blocks=%d us_per_call=%.3f us_per_block=%.4f\n",
M, pool, zero_ws, blocks, us, us / (blocks > 0 ? blocks : 1));
return 0;
}
2 changes: 1 addition & 1 deletion docs/BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ in the tree, default-OFF, for reproducibility; detail in the benchmark record.
| MTP | Qwen3.6-27B NVFP4 | token-identical to vLLM MTP, **~4% faster at c1**; on-par at c2-c8 | `DONE` |
| DFlash | Qwen3.6-27B NVFP4 | **2.9x over spec-off** (10.16 → 29.32 tok/s), at/above vLLM DFlash-on (**1.003x**, non-overlapping bands) | `DONE` |
| n-gram | Qwen3.6-27B NVFP4 | draft-free (`SPEC-NGRAM`); 27B 5/5 STRICT our-ngram-ON == vLLM-ngram-ON, 180/180 drafts accepted (correctness only, no speed row yet) | `DONE` |
| DSpark | 27B NVFP4 dense k=15; 35B-A3B MoE k=8 | MoE **0.975x** code / **1.012x** prose vs the pinned graphed oracle (PINNED CLOCKS, non-overlapping). NOT parity: **~0.966x +/- 0.01** over three within-session pairs; C_tmp cap perf-NEUTRAL; storage refuted (#442) | `ACTIVE` |
| DSpark | 27B NVFP4 dense k=15; 35B-A3B MoE k=8 | MoE 35B-A3B **~0.98x** of the pinned graphed oracle: one VALID controlled paired run at 0.9889 (drift -0.33%), repeat REJECTED on a -2.13% drift gate. NOT parity (#442) | `ACTIVE` |
| Breadth (EAGLE1/3, suffix, ngram-gpu, dynamic-k, ...) | n/a | enumerated from vLLM source + `INVENTORIED` 2026-08-06 (`.agents/specs/spec-decode-inventory.md`), unmeasured | `INVENTORIED` |

## How we measure
Expand Down
98 changes: 79 additions & 19 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,25 +528,85 @@ MHz, 30 reps, drift bracketed at -0.088%, the oracle's non-modal draws excluded)
the code cell is **0.975x with NON-OVERLAPPING distributions** — a real gap, not
noise, and the earlier "within resolution" reading was too generous. Ours slowed
more than the oracle when the clock was pinned, so the residual is
SM-clock-sensitive work. PAIRED profiling localises it exactly: the SAME
`marlin_moe_wna16::Marlin` kernel, the SAME 1520 launches, ours 249.22 ms vs
upstream 230.39 ms -- **8.2% slower inside one kernel**, which at ~34% of wall is
2.8% end-to-end and accounts for the whole measured 2.5%. Not an algorithm difference, and not the launch
geometry either: the full template arguments match, `determine_exec_config` is
byte-identical to the pinned upstream copy, and every OTHER kernel matches to
0.2%. The inputs match too (scale bytes per expert,
256-byte alignment, cudaMalloc residency), and the work counts were MEASURED:
upstream loops 4.4% MORE blocks per launch (40.6 vs 38.9) and is still faster, so
routing is refuted and normalising by work makes our deficit bigger -- **4.21 vs
3.73 us per block, ~12.8% slower per unit of work**. Every source-level explanation is now
eliminated -- kernel source, template instantiation, grid config, block size,
shared-memory budget, reduction flags, scale layout, alignment, residency, CUDA
toolkit (13.0 both) and arch all match -- and `ncu` plus cuobjdump then showed the
COMPILED KERNELS ARE EQUIVALENT (94 registers and 3664 SASS instructions on both,
upstream running its family-compatible sm_120 cubin against our sm_121a). The
residual is therefore runtime and is now ATTRIBUTED: the kernel is DRAM-bound
(L2 hit 9.5%) and we sustain **186.6 GB/s against upstream's 210.7**, a 12.9%
effective-bandwidth gap that IS the whole per-unit-work difference. Weight
SM-clock-sensitive work. PAIRED profiling appeared to localise it to `marlin_moe_wna16::Marlin` (ours
249.22 ms vs upstream 230.39 ms over the same 1520 launches), and every
source-level explanation was eliminated -- kernel source, template
instantiation, grid, block size, shared memory, flags, scale layout,
alignment, residency, toolkit, arch -- with `ncu` and cuobjdump showing the
compiled kernels EQUIVALENT (94 registers, 3664 SASS instructions on
both). **That localisation is now REFUTED.** `scripts/marlin-moe-standalone.py` and
`benchmarks/marlin_moe_standalone.cpp` drive each engine's own kernel outside
its engine -- which is also what finally lets `ncu` attach to upstream, the
blocker recorded as impossible in both replay modes -- and at matched work the
two are indistinguishable: over 12 interleaved paired points ours
averages **5.3187 us/block against upstream's 5.3330**, ratio **0.9973**, sign flipping
between runs, inside one standard deviation either way. The in-situ 8.2%
therefore describes the RUNS, not the kernel, and so do the 12.8%-per-unit-
work and 186.6-vs-210.7 GB/s figures derived from it. What the harness does
establish is the kernel's shape: a persistent single wave (grid 144 = 48 SMs x
3 blocks, 32768 B shared against 102400 B/SM, 25% occupancy), a bandwidth-
limited plateau of 203-226 GB/s that BOTH engines reach, and a 4.6x swing
driven by how many DISTINCT EXPERTS a launch touches (1.15 us/block at 16,
weights in L2; ~5.3 above ~27, streaming). Blocks are not experts, and holding blocks fixed while varying distinct
experts settles it: at M=128, going 137 to 146 blocks (+6.6%) while distinct
experts doubled cost +46.7% TIME, and cost per DISTINCT EXPERT is flat at
5.2-5.7 us across the table while cost per block varies 4.7x. Time is
distinct_experts x 1.125 MiB / ~215 GB/s and blocks are nearly irrelevant, so
every in-situ comparison normalised by the wrong quantity. At the M=9 decode shape blocks and distinct experts COINCIDE (72 pairs over
~39 experts, under 8 each, one block per expert), so the both-sides count
already measured experts: ours 38.9 against upstream's 40.6. That leaves a
sharp contradiction rather than an explanation. Standalone at matched work the
kernels are equal to 0.27%; in situ ours does LESS work and takes MORE time
(164.0 us against 151.6). A kernel identical in isolation cannot be slower in
place because of its own code, so the deficit belongs to the CONTEXT, not the
kernel and not the routing. Candidates in evidence order: expert-weight
residency in situ, where this repo has already measured 20-30% per GEMM for
host/ATS-retagged decode weights and the standalone arm's fresh cudaMalloc
cannot reproduce it; clock and power state across runs; and overlap with
concurrent stream work. Two arithmetic corrections then close it out. The 1520 in-situ launches are
760 gate_up plus 760 down, and down's per-expert bytes are exactly half, so
the mixed average is 0.8438 MiB per expert-block; comparing that against a
gate_up-only standalone plateau is what made both arms look like they beat it.
Redone correctly, ours implies 209.9 GB/s and upstream 236.9 against a
measured 203-226 plateau, so OURS SITS INSIDE IT AND UPSTREAM ABOVE IT: we run
this kernel at the bandwidth it achieves in isolation and upstream gets
something in place that the isolated kernel does not, cache reuse across the
gate_up/down pair being the first candidate. Second, every in-situ per-unit-
work ratio has a mode hole: the 38.9/40.6 block counts were taken EAGER
because the probes could not survive capture, while the 249.2/230.4 ms times
came from the GRAPHED profile, so numerator and denominator are from different
execution modes. The only like-for-like Marlin comparison in evidence is
therefore the standalone one, and it says parity. Closing the in-situ question
needs blocks and time from the SAME graphed run, via a device-side counter
read once at the end rather than a per-launch D2H sync. None of this moves the
end-to-end ratio, which is wall-clock on matched prompts. The kernel's one
settable occupancy knob, blocks_per_sm, was swept and is DEAD: with routing
SEEDED the between-configuration spread (4.3%) is no larger than the within-
configuration spread, and an apparent 8.7% win came from an unseeded pass
where the routing draw moved. THE HEADLINE THEN CHANGED. Every earlier ratio
was taken with a COLD leading arm. The first run with all four controls
present -- the correct $HOME/gpu.lock, a DISCARDED warm-up arm (the GB10 SM
clock ramps over minutes, 1449 to 2190 MHz, so dropping rep 1 leaves a whole
arm ~6% low), settle barriers (vLLM asserts free GPU memory does not grow
during its startup profile, and GB10 releases our pages lazily, which killed
every earlier paired attempt), and a host-RAM headroom guard
(gpu_memory_utilization reserves HOST RAM here, which took the machine down
three times on 2026-08-13) -- measures ours at 142.534 tok/s against the oracle's 144.130, a ratio
of **0.9889** with before/after drift of -0.33%, inside the 1% validity gate. So
the gap is ~1%, not 3.4%: the 0.9757/0.9646/0.9569 recorded earlier were
measuring an unwarmed first arm as much as the engine. The n=2 repeat RAN and
was REJECTED by the same gate at -2.13% drift, so its 0.9795 is not averaged
in and the standing claim is ~0.98 on ONE valid paired run, still NOT parity.
The repeat did establish two things: the oracle's bimodality is BOOT-DEPENDENT
(unimodal near 144 in one run, 10-at-148 plus 5-at-157 in the next, same
script and pin), so no harness may assume either shape; and both arms'
absolutes moved together across the reboot, ours +1.8% and oracle +2.8%,
matching the recorded 12.8% boot-to-boot clock variation. The binding
constraint on resolving 1% here is the box, which rebooted or dropped five
times on 2026-08-13. Two traps worth carrying: dram__bytes.sum reads n/a on GB10, so
ncu's Memory Throughput % excludes DRAM traffic; and fuser -v $HOME/gpu.lock
is the check, because nvidia-smi showing no compute apps does not mean the GPU
is unreserved. Weight
residency is already staged correctly (cudaMalloc + one upload), and the slab itself is byte-for-byte the
same size and stride as upstream's tensor (268 MB, no padding), so the cause is
memory-system behaviour that no allocation change we can name would alter; upstream's ncu counters would settle it but its engine will not initialise under
Expand Down
Loading
Loading