File size: 17,407 Bytes
eae424a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad454b
 
 
eae424a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad454b
eae424a
 
 
 
 
 
 
3ad454b
 
eae424a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ad454b
 
 
eae424a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! Throughput measurement, shared by the desktop and on-device entry
//! points.
//!
//! The question this exists to answer: the Adreno in a Quest 3 is not
//! expected to expose `VK_KHR_cooperative_matrix`, so meganeura will fall
//! back to its register-tiled scalar matmul. Whether the encoder then
//! lands at 20 ms or 200 ms per frame decides the input resolution, the
//! inference rate, and whether the render loop has to be decoupled from
//! inference (it almost certainly does).
//!
//! Everything here reports achieved GFLOP/s next to the theoretical MAC
//! count, so a number that looks fast can be checked against what the
//! device could possibly do.

use std::sync::Arc;
use std::time::Instant;

use meganeura::graph::Op;
use meganeura::train::{Mode, SessionConfig};
use meganeura::{Graph, Session};

use crate::dinov3::Config;

/// One timing result.
#[derive(Debug, Clone)]
pub struct Timing {
    pub label: String,
    /// Minimum retained sample. Useful diagnostically, but not the primary
    /// result: selecting the minimum biases comparisons on a noisy device.
    pub best_ms: f64,
    pub mean_ms: f64,
    pub median_ms: f64,
    pub p25_ms: f64,
    pub p75_ms: f64,
    pub max_ms: f64,
    /// Every retained synchronized wall-time observation.
    pub samples_ms: Vec<f64>,
    /// Multiply-accumulates per iteration, for the GFLOP/s figure.
    pub macs: u64,
}

impl Timing {
    /// Median achieved GFLOP/s, counting a multiply-accumulate as two
    /// operations.
    pub fn gflops(&self) -> f64 {
        (self.macs as f64 * 2.0) / (self.median_ms / 1000.0) / 1e9
    }

    /// One self-contained JSON record suitable for retaining from logcat.
    pub fn json_line(&self) -> String {
        let label = self.label.replace('\\', "\\\\").replace('"', "\\\"");
        let samples = self
            .samples_ms
            .iter()
            .map(|v| format!("{v:.6}"))
            .collect::<Vec<_>>()
            .join(",");
        format!(
            "{{\"schema_version\":1,\"kind\":\"dinovision_benchmark\",\
             \"label\":\"{label}\",\"macs\":{},\"median_ms\":{:.6},\
             \"p25_ms\":{:.6},\"p75_ms\":{:.6},\"min_ms\":{:.6},\
             \"max_ms\":{:.6},\"mean_ms\":{:.6},\"samples_ms\":[{samples}]}}",
            self.macs,
            self.median_ms,
            self.p25_ms,
            self.p75_ms,
            self.best_ms,
            self.max_ms,
            self.mean_ms,
        )
    }
}

impl std::fmt::Display for Timing {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{:<28} median {:>8.2} ms  IQR [{:>7.2}, {:>7.2}]  min/max [{:>7.2}, {:>7.2}]  {:>7.1} GFLOP/s",
            self.label,
            self.median_ms,
            self.p25_ms,
            self.p75_ms,
            self.best_ms,
            self.max_ms,
            self.gflops()
        )
    }
}

/// Log the device identity and whether cooperative matrix is usable.
///
/// This is the single most important line of the whole benchmark: it
/// determines which matmul kernel every subsequent number came from.
pub fn describe_device(gpu: &blade_graphics::Context) {
    let info = gpu.device_information();
    log::info!("device       : {}", info.device_name);
    log::info!("driver       : {} {}", info.driver_name, info.driver_info);

    let caps = meganeura::runtime::auto_tune(gpu, 0).coop_caps;
    if caps.f16_tile == 0 && caps.f32_tile == 0 {
        log::warn!("coop matrix  : NOT AVAILABLE — expect the register-tiled scalar matmul");
    } else {
        log::info!(
            "coop matrix  : available (f32 tile {}, f16 tile {})",
            caps.f32_tile,
            caps.f16_tile
        );
    }
}

/// Fill every parameter with small deterministic pseudo-random values.
///
/// Timing is not sensitive to weight *values*, but leaving parameters at
/// zero would make every activation zero, and a benchmark whose data is
/// entirely one value invites doubt about data-dependent fast paths.
/// Cheap insurance.
pub fn fill_parameters(session: &mut Session, graph: &Graph) {
    let mut state = 0x2545_F491_4F6C_DD1Du64;
    let mut scratch = Vec::new();
    for node in graph.nodes() {
        let Op::Parameter { name } = &node.op else {
            continue;
        };
        scratch.clear();
        scratch.reserve(node.ty.num_elements());
        for _ in 0..node.ty.num_elements() {
            // xorshift64*, inlined to avoid a dependency.
            state ^= state >> 12;
            state ^= state << 25;
            state ^= state >> 27;
            let bits = state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40;
            scratch.push((bits as f32 / (1u32 << 24) as f32 - 0.5) * 0.05);
        }
        session.set_parameter(name, &scratch);
    }
}

pub(crate) fn time_session(session: &mut Session, label: &str, macs: u64, iters: usize) -> Timing {
    // Five is the paper protocol's minimum. The environment override exists
    // for diagnostic sweeps, and is recorded alongside the raw samples by
    // the calling artifact script.
    let warmups = std::env::var("DINOVISION_WARMUPS")
        .ok()
        .and_then(|s| s.parse::<usize>().ok())
        .unwrap_or(5);
    for _ in 0..warmups {
        session.step();
        session.wait();
    }

    let mut samples_ms = Vec::with_capacity(iters.max(1));
    for _ in 0..iters.max(1) {
        let start = Instant::now();
        session.step();
        session.wait();
        samples_ms.push(start.elapsed().as_secs_f64() * 1000.0);
    }

    let mut sorted = samples_ms.clone();
    sorted.sort_by(f64::total_cmp);
    let quantile = |q: f64| {
        let at = q * (sorted.len() - 1) as f64;
        let lo = at.floor() as usize;
        let hi = at.ceil() as usize;
        sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo as f64)
    };
    Timing {
        label: label.to_string(),
        best_ms: sorted[0],
        mean_ms: samples_ms.iter().sum::<f64>() / samples_ms.len() as f64,
        median_ms: quantile(0.5),
        p25_ms: quantile(0.25),
        p75_ms: quantile(0.75),
        max_ms: sorted[sorted.len() - 1],
        samples_ms,
        macs,
    }
}

/// Time a bare `[m, k] @ [k, n]` matmul.
///
/// Run at the shapes the encoder actually uses, so the result is an upper
/// bound on what the encoder could reach rather than a peak-throughput
/// number from an unrepresentatively large matrix.
pub fn matmul_throughput(
    gpu: Arc<blade_graphics::Context>,
    m: usize,
    k: usize,
    n: usize,
    iters: usize,
) -> Timing {
    let mut g = Graph::new();
    let a = g.input("a", &[m, k]);
    let b = g.parameter("b", &[k, n]);
    let y = g.matmul(a, b);
    g.set_outputs(vec![y]);

    let (mut session, _) = meganeura::train::build(
        &g,
        SessionConfig {
            mode: Mode::Inference,
            gpu: Some(gpu),
            ..Default::default()
        },
    );
    fill_parameters(&mut session, &g);
    session.set_input("a", &vec![0.01f32; m * k]);

    time_session(
        &mut session,
        &format!("matmul {m}x{k}x{n}"),
        (m * k * n) as u64,
        iters,
    )
}

/// Sweep matmul shapes to find what the kernel is actually short of.
///
/// This diagnostic separates three plausible sources of lost utilization;
/// no operation-share or f16 conclusion is inferred from it without a
/// matched end-to-end run:
///
/// * **Tail waste.** `m` is 201 — 196 patches, CLS, 4 registers — against
///   `BM = 64`, so the last tile row is 9/64 useful and 21% of every
///   dispatch is padding. Padding `m` to 256 does 27% more arithmetic; if
///   the wall time does not move, we were paying for it already.
/// * **Occupancy.** Tiles are 64×64 with `KTILE = 32`, so a workgroup holds
///   16.4 KB of shared memory and few stay resident. Widening `n` at fixed
///   cost per workgroup raises the workgroup count; if throughput climbs
///   with it, the device is starved rather than saturated. This is also the
///   one thing f16 would genuinely fix, by halving the LDS footprint.
/// * **Prologue cost.** `k = 384` is 12 k-tiles, so the per-tile load and
///   double barrier amortize over very few iterations. Deepening `k` at the
///   same output size isolates that.
///
/// The large square shapes at the end give the kernel's ceiling with every
/// one of those effects removed, which is the number f16 arithmetic would
/// have to improve on to be worth the work.
pub fn matmul_shapes(gpu: Arc<blade_graphics::Context>, iters: usize) -> Vec<Timing> {
    let mut results = Vec::new();
    for (m, k, n, note) in [
        (201, 384, 1536, "encoder MLP up, as it runs"),
        (256, 384, 1536, "m padded to a whole tile: +27% MACs"),
        (201, 384, 384, "24 workgroups"),
        (201, 384, 1536, "96 workgroups"),
        (201, 384, 6144, "384 workgroups"),
        (201, 1536, 1536, "4x the k-tiles per workgroup"),
        (512, 512, 512, "square, no tail"),
        (1024, 1024, 1024, "square, no tail"),
        (2048, 2048, 2048, "square, kernel ceiling"),
    ] {
        let t = matmul_throughput(gpu.clone(), m, k, n, iters);
        log::info!("{t}   ({note})");
        results.push(t);
    }

    // Probe the tail-waste hypothesis. Every token-major matmul has `m = tokens`, and
    // 201 tokens occupy four 64-row tiles — 256 rows, of which 55 are
    // padding we already pay for. A 240² input gives a 15×15 grid, so 225
    // patches plus 5 prefix tokens is 230 rows: still four tiles, still the
    // same dispatch, but 15% more spatial resolution reaching the decoder.
    //
    // Not entirely free — attention scores are O(tokens²) and they are ~8%
    // of the layer — so measure it rather than assert it.
    // Bracket 240 with repeated 224 cells so time drift is visible rather than
    // silently attributed to the middle shape.
    for size in [224, 240, 224] {
        let c = Config::vits16().at_resolution(size).with_layers(3);
        let mut t = encoder_forward(gpu.clone(), &c, iters);
        t.label = format!("dinov3 @{size} 3L");
        log::info!(
            "{t}   (grid {}x{}, {} tokens)",
            c.grid(),
            c.grid(),
            c.num_tokens()
        );
        results.push(t);
    }
    results
}

/// Build an encoder session on the shared context.
///
/// Weights are left to the caller: [`crate::weights::load_encoder`] for
/// real features, [`fill_parameters`] for pure timing.
pub fn build_encoder_session(
    gpu: Arc<blade_graphics::Context>,
    config: &Config,
    cache: Option<&std::path::Path>,
) -> (Session, Graph) {
    let mut g = Graph::new();
    let out = crate::dinov3::build_encoder(&mut g, config);
    g.set_outputs(vec![out]);

    let build_start = Instant::now();
    let (session, _) = meganeura::train::build(
        &g,
        SessionConfig {
            mode: Mode::Inference,
            gpu: Some(gpu),
            cache,
            ..Default::default()
        },
    );
    // Worth reporting separately: on a mobile CPU, graph optimization plus
    // WGSL-to-SPIR-V for every kernel is slow enough to matter at startup,
    // and it is exactly what the plan cache is meant to remove.
    log::info!(
        "session build: {:.1} s (cache {})",
        build_start.elapsed().as_secs_f64(),
        if cache.is_some() { "on" } else { "off" }
    );
    (session, g)
}

/// Time a full DINOv3 forward pass with synthetic weights.
pub fn encoder_forward(gpu: Arc<blade_graphics::Context>, config: &Config, iters: usize) -> Timing {
    let (mut session, g) = build_encoder_session(gpu, config, None);
    fill_parameters(&mut session, &g);
    session.set_input(
        "patches",
        &vec![0.1f32; config.num_patches() * config.patch_dim()],
    );

    time_session(
        &mut session,
        &format!("dinov3 vits16 @{}", config.image_size),
        config.forward_macs(),
        iters,
    )
}

/// Time the deployed joined encoder-to-RGB graph, including decoder and all
/// graph-internal layout transformations. Camera conversion, CPU patchifying,
/// output readback, and rendering are outside this isolated measurement.
pub fn roundtrip_forward(
    gpu: Arc<blade_graphics::Context>,
    config: &Config,
    submission_chunks: usize,
    iters: usize,
) -> Timing {
    let mut graph = Graph::new();
    let encoder_output = crate::dinov3::build_encoder(&mut graph, config);
    let reconstruction = crate::decoder::attach_to_encoder(&mut graph, config, encoder_output);
    graph.set_outputs(vec![reconstruction]);
    let (mut session, _) = meganeura::train::build(
        &graph,
        SessionConfig {
            mode: Mode::Inference,
            gpu: Some(gpu),
            ..Default::default()
        },
    );
    session.set_submission_chunks(submission_chunks);
    fill_parameters(&mut session, &graph);
    session.set_input(
        "patches",
        &vec![0.1f32; config.num_patches() * config.patch_dim()],
    );
    time_session(
        &mut session,
        &format!(
            "roundtrip @{} {}L x{}",
            config.image_size, config.num_hidden_layers, submission_chunks
        ),
        config.forward_macs() + crate::decoder::forward_macs(config),
        iters,
    )
}

/// The full benchmark: device identity, matmul throughput at the shapes
/// the encoder uses, then the encoder itself across candidate input
/// resolutions.
pub fn run_all(gpu: Arc<blade_graphics::Context>, iters: usize) -> Vec<Timing> {
    describe_device(&gpu);

    let base = Config::vits16();
    let tokens = base.num_tokens();
    let hidden = base.hidden_size;
    let mut results = Vec::new();

    // The three matmul shapes that dominate a ViT-S forward pass.
    for (m, k, n) in [
        (tokens, hidden, hidden),                 // Q/K/V/output projection
        (tokens, hidden, base.intermediate_size), // MLP up
        (tokens, base.intermediate_size, hidden), // MLP down
    ] {
        let t = matmul_throughput(gpu.clone(), m, k, n, iters);
        log::info!("{t}");
        results.push(t);
    }

    // 224 is the reference resolution (14×14 features); 256 buys a 16×16
    // grid for ~30% more work. Anything larger is unlikely to fit a
    // real-time budget, which is what these numbers will confirm or deny.
    for size in [224, 256] {
        let config = base.clone().at_resolution(size);
        let t = encoder_forward(gpu.clone(), &config, iters);
        log::info!(
            "{t}   (grid {}x{}, {} tokens)",
            config.grid(),
            config.grid(),
            config.num_tokens()
        );
        results.push(t);
    }

    // ViT-S/16 is the smallest DINOv3 there is, so cheaper means fewer
    // layers or fewer tokens rather than a smaller model. Measure both
    // knobs, since which one to spend is a quality judgement that wants
    // real numbers under it.
    for layers in [6, 3] {
        let c = base.clone().at_resolution(224).with_layers(layers);
        let mut t = encoder_forward(gpu.clone(), &c, iters);
        t.label = format!("dinov3 @224 {layers}L");
        log::info!("{t}   ({layers} of 12 layers)");
        results.push(t);
    }
    for size in [160, 128] {
        let c = base.clone().at_resolution(size);
        let mut t = encoder_forward(gpu.clone(), &c, iters);
        t.label = format!("dinov3 vits16 @{size}");
        log::info!("{t}   (grid {}x{})", c.grid(), c.grid());
        results.push(t);
    }

    // The actual reconstruction workload is a joined three-layer encoder and
    // decoder. Keep isolated chunk costs beside the live-XR sweep so queue
    // fairness is not presented without its throughput price.
    let reconstruction = base.clone().at_resolution(224).with_layers(3);
    // The order is fixed before corrected device data and deliberately not
    // monotonic, matching the live sweep's protection against time drift.
    for chunks in [4, 16, 1, 12, 2, 8] {
        let t = roundtrip_forward(gpu.clone(), &reconstruction, chunks, iters);
        log::info!("{t}   (joined encoder + decoder, {chunks} submission chunks)");
        results.push(t);
    }

    // f16 weight storage halves weight traffic. Whether that shows up
    // depends on whether the device is short of bandwidth or of ALU, which
    // is exactly the thing worth measuring rather than reasoning about.
    let f16 = base.clone().at_resolution(224).with_f16_weights(true);
    let mut t = encoder_forward(gpu.clone(), &f16, iters);
    t.label = "dinov3 vits16 @224 f16w".to_string();
    log::info!("{t}   (f16 weight storage, f32 arithmetic)");
    results.push(t);

    // Chunked submission should cost a little throughput — several submits
    // instead of one — in exchange for letting a co-tenant interleave. This
    // measures the price when nothing else is on the queue.
    let chunked = base.clone().at_resolution(224);
    let (mut session, g) = build_encoder_session(gpu, &chunked, None);
    session.set_submission_chunks(12);
    fill_parameters(&mut session, &g);
    session.set_input(
        "patches",
        &vec![0.1f32; chunked.num_patches() * chunked.patch_dim()],
    );
    let t = time_session(
        &mut session,
        "dinov3 vits16 @224 x12",
        chunked.forward_macs(),
        iters,
    );
    log::info!("{t}   (12 submissions instead of 1)");
    results.push(t);

    results
}