Skip to content

Commit 924f541

Browse files
committed
test: extend compile microbench with --layers and dispatch-only timing
Add `--layers N` (stack the block to forward-scale op counts) and a dispatch-only measurement (build + async_eval, GPU drain outside the timed region) to the M6 compile de-risk harness. This isolates host-side dispatch (graph build + MLX encode) from GPU compute and shows mx::compile amortizes dispatch only ~1.48x at 48-layer scale (vs 1.09x full, GPU-dominated) — informing the gem_chat decode bottleneck analysis.
1 parent dd688ec commit 924f541

1 file changed

Lines changed: 59 additions & 7 deletions

File tree

bench/native/compile_microbench.cpp

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ namespace mx = mlx::core;
3939
// 1000 iterations is fast to run yet long enough that fusion matters.
4040
constexpr int kBatch = 1;
4141
static int kSeq = 128; // overridable via --seq
42+
static int kLayers = 1; // overridable via --layers (stack the block)
4243
constexpr int kHidden = 1024;
4344
constexpr int kHeads = 16;
4445
constexpr int kHeadDim = 64; // kHeads * kHeadDim = 1024 = kHidden
@@ -129,6 +130,18 @@ static std::vector<mx::array> block(const std::vector<mx::array>& in) {
129130
return {out};
130131
}
131132

133+
// Stack the block kLayers times (reusing weights — fine for timing). This
134+
// reaches forward-scale op counts (~15 ops/block × 48 ≈ 720, matching the
135+
// Gemma decode forward's ~750) so dispatch (build+encode) is large enough to
136+
// dominate, isolating whether mx::compile amortizes it.
137+
static std::vector<mx::array> multi_block(const std::vector<mx::array>& in) {
138+
std::vector<mx::array> cur = in;
139+
for (int l = 0; l < kLayers; ++l) {
140+
cur[0] = block(cur)[0];
141+
}
142+
return {cur[0]};
143+
}
144+
132145
// ---------------------------------------------------------------------
133146

134147
struct Stats {
@@ -200,20 +213,48 @@ static Stats time_runs(Fn&& fn, int warmup, int iters) {
200213
return summarise(samples);
201214
}
202215

216+
// Dispatch-only timing: time [build graph + async_eval] WITHOUT waiting for
217+
// the GPU (synchronize happens outside the timed region). This isolates the
218+
// host-side dispatch cost (graph build + MLX encode/schedule) — the ~68 ms/tok
219+
// that dominates Gemma decode — from GPU compute. The question: does compile
220+
// shrink THIS, not the GPU.
221+
template <typename Fn>
222+
static Stats time_dispatch(Fn&& fn, int warmup, int iters) {
223+
for (int i = 0; i < warmup; ++i) {
224+
auto out = fn();
225+
mx::async_eval(out);
226+
mx::synchronize();
227+
}
228+
229+
std::vector<double> samples;
230+
samples.reserve(iters);
231+
232+
for (int i = 0; i < iters; ++i) {
233+
auto start = std::chrono::high_resolution_clock::now();
234+
auto out = fn();
235+
mx::async_eval(out); // schedule; returns after encode, before GPU completes
236+
auto end = std::chrono::high_resolution_clock::now();
237+
mx::synchronize(); // drain GPU OUTSIDE the timed region
238+
samples.push_back(std::chrono::duration<double, std::milli>(end - start).count());
239+
}
240+
241+
return summarise(samples);
242+
}
243+
203244
static void run_on_device(mx::Device::DeviceType dev_type, const char* label,
204245
int warmup, int iters) {
205246
mx::set_default_device(mx::Device(dev_type));
206247

207248
auto inputs = make_inputs();
208249

209-
// Uncompiled baseline: call block() directly on each iteration.
250+
// Uncompiled baseline: rebuild the (kLayers-stacked) graph each iteration.
210251
auto uncompiled_fn = [&inputs]() -> std::vector<mx::array> {
211-
return block(inputs);
252+
return multi_block(inputs);
212253
};
213254

214-
// Compiled: wrap block in mx::compile. First call of the returned
215-
// closure does the trace; subsequent calls hit the fused tape.
216-
auto compiled_closure = mx::compile(block, /*shapeless=*/false);
255+
// Compiled: wrap in mx::compile. First call traces; subsequent calls hit the
256+
// cached tape (skipping the graph build/simplify).
257+
auto compiled_closure = mx::compile(multi_block, /*shapeless=*/false);
217258
auto compiled_fn = [&compiled_closure, &inputs]() -> std::vector<mx::array> {
218259
return compiled_closure(inputs);
219260
};
@@ -236,8 +277,17 @@ static void run_on_device(mx::Device::DeviceType dev_type, const char* label,
236277
std::printf(" speedup (median) = %.3fx (min) = %.3fx\n",
237278
median_speedup, min_speedup);
238279

239-
bool passes = median_speedup >= 1.20;
240-
std::printf(" gate (>=1.20x on median): %s\n", passes ? "PASS" : "FAIL");
280+
// Dispatch-only: the host-side build+encode cost (no GPU wait) — the thing
281+
// that dominates Gemma decode. Does compile amortize it?
282+
auto unc_disp = time_dispatch(uncompiled_fn, warmup, iters);
283+
auto cmp_disp = time_dispatch(compiled_fn, warmup, iters);
284+
std::printf(" [dispatch-only, no GPU wait]\n");
285+
std::printf(" uncompiled min=%.3f ms median=%.3f ms\n",
286+
unc_disp.min_ms, unc_disp.median_ms);
287+
std::printf(" compiled min=%.3f ms median=%.3f ms\n",
288+
cmp_disp.min_ms, cmp_disp.median_ms);
289+
std::printf(" dispatch speedup (median) = %.3fx\n",
290+
unc_disp.median_ms / cmp_disp.median_ms);
241291
}
242292

243293
// ---------------------------------------------------------------------
@@ -310,6 +360,8 @@ int main(int argc, char** argv) {
310360
iters = std::atoi(argv[++i]);
311361
} else if (std::strcmp(argv[i], "--seq") == 0 && i + 1 < argc) {
312362
kSeq = std::atoi(argv[++i]);
363+
} else if (std::strcmp(argv[i], "--layers") == 0 && i + 1 < argc) {
364+
kLayers = std::atoi(argv[++i]);
313365
}
314366
}
315367

0 commit comments

Comments
 (0)