@@ -39,6 +39,7 @@ namespace mx = mlx::core;
3939// 1000 iterations is fast to run yet long enough that fusion matters.
4040constexpr int kBatch = 1 ;
4141static int kSeq = 128 ; // overridable via --seq
42+ static int kLayers = 1 ; // overridable via --layers (stack the block)
4243constexpr int kHidden = 1024 ;
4344constexpr int kHeads = 16 ;
4445constexpr 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
134147struct 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+
203244static 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