headroom 0.1.0 copy "headroom: ^0.1.0" to clipboard
headroom: ^0.1.0 copied to clipboard

A two-second on-device probe that tells your app how fast an LLM of a given size will decode on this phone, whether it fits, and on what basis.

example/lib/main.dart

// Flutter's animation library also has an `Interval`; headroom's is the one
// used here.
import 'package:flutter/material.dart' hide Interval;
import 'package:flutter/services.dart';
import 'package:headroom/headroom.dart';

void main() => runApp(const HeadroomExampleApp());

/// One Probe button, a report card, an estimate table for four model sizes,
/// and Copy JSON for pasting a phone's result back into the calibration.
class HeadroomExampleApp extends StatelessWidget {
  const HeadroomExampleApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Headroom',
    theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
    darkTheme: ThemeData(
      colorSchemeSeed: Colors.teal,
      brightness: Brightness.dark,
      useMaterial3: true,
    ),
    home: const ProbePage(),
  );
}

class ProbePage extends StatefulWidget {
  const ProbePage({super.key});

  @override
  State<ProbePage> createState() => _ProbePageState();
}

class _ProbePageState extends State<ProbePage> {
  ProbeReport? _report;
  String? _error;
  bool _running = false;

  Future<void> _probe() async {
    setState(() {
      _running = true;
      _error = null;
    });
    try {
      final report = await Headroom.probe();
      setState(() => _report = report);
    } on ProbeCancelledException {
      // The button was pressed again; nothing to show.
    } on HeadroomException catch (error) {
      setState(() => _error = error.toString());
    } finally {
      if (mounted) setState(() => _running = false);
    }
  }

  Future<void> _copyJson() async {
    final report = _report;
    if (report == null) return;
    await Clipboard.setData(ClipboardData(text: report.toJsonString()));
    if (!mounted) return;
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('Report JSON copied')));
  }

  @override
  Widget build(BuildContext context) {
    final report = _report;
    final error = _error;
    return Scaffold(
      appBar: AppBar(title: const Text('Headroom')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text(
            'A two-second probe of this device\'s memory bandwidth, and what '
            'it means for a model of a given size. Every figure says whether '
            'it was measured here or calibrated elsewhere.',
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
              color: Theme.of(context).colorScheme.onSurfaceVariant,
            ),
          ),
          const SizedBox(height: 16),
          FilledButton.icon(
            onPressed: _running ? Headroom.cancel : _probe,
            icon: _running
                ? const SizedBox.square(
                    dimension: 18,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Icon(Icons.speed),
            label: Text(_running ? 'Cancel' : 'Probe'),
          ),
          if (error != null) ...[
            const SizedBox(height: 16),
            Text(
              error,
              style: TextStyle(
                color: Theme.of(context).colorScheme.error,
                fontFamily: 'monospace',
                fontSize: 12,
              ),
            ),
          ],
          if (report != null) ...[
            const SizedBox(height: 16),
            ReportCard(report: report),
            const SizedBox(height: 16),
            EstimateTable(report: report),
            const SizedBox(height: 16),
            OutlinedButton.icon(
              onPressed: _copyJson,
              icon: const Icon(Icons.copy),
              label: const Text('Copy JSON'),
            ),
          ],
        ],
      ),
    );
  }
}

/// Ceilings with their confidence intervals and basis, then the conditions
/// the probe ran under.
class ReportCard extends StatelessWidget {
  const ReportCard({super.key, required this.report});

  final ProbeReport report;

  @override
  Widget build(BuildContext context) {
    final device = report.device;
    final gpu = report.gpu;
    final cpu = report.cpu;
    final memory = report.memory;
    return _Card(
      children: [
        Text(
          '${device.identifier} · ${device.platform.jsonName} '
          '${device.osVersion} (${device.osBuild})'
          '${device.chip == null ? '' : ' · ${device.chip}'}',
          style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
        ),
        const SizedBox(height: 12),
        if (gpu != null)
          _CeilingRow(title: 'GPU ceiling (Metal triad)', figure: gpu.triad)
        else
          const _LabelledRow(
            title: 'GPU ceiling',
            value: 'not measured',
            basis: Basis.unknown,
          ),
        const SizedBox(height: 8),
        if (cpu != null)
          _CeilingRow(
            title: 'CPU triad, ${cpu.threads} threads'
                '${cpu.attempts.length > 1 ? ' (best of ${cpu.attempts.length})' : ''}',
            figure: cpu.triad,
          )
        else
          const _LabelledRow(
            title: 'CPU triad',
            value: 'not measured',
            basis: Basis.unknown,
          ),
        const Divider(height: 24),
        _LabelledRow(
          title: 'Thermal',
          value: report.conditions.thermalState.name +
              (report.conditions.platformThermalStatus == null
                  ? ''
                  : ' (${report.conditions.platformThermalStatus})'),
          basis: Basis.measured,
        ),
        _LabelledRow(
          title: 'Power',
          value: _powerDescription(report.conditions),
          basis: Basis.measured,
        ),
        _LabelledRow(
          title: 'Memory',
          value: '${(memory.physicalBytes / 1e9).toStringAsFixed(2)} GB physical',
          basis: Basis.measured,
        ),
        _LabelledRow(
          title: 'Available',
          value: '${(memory.availableBytes.value / 1e9).toStringAsFixed(2)} GB'
              '${memory.lowMemory == true ? ', low memory' : ''}',
          basis: memory.availableBytes.basis,
        ),
        for (final warning in report.warnings) ...[
          const SizedBox(height: 8),
          Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Icon(Icons.warning_amber, size: 16, color: Colors.orange),
              const SizedBox(width: 6),
              Expanded(
                child: Text(
                  warning,
                  style: const TextStyle(fontSize: 12, color: Colors.orange),
                ),
              ),
            ],
          ),
        ],
      ],
    );
  }

  static String _powerDescription(Conditions conditions) {
    final buffer = StringBuffer(conditions.powerSource.name);
    final level = conditions.batteryLevel;
    if (level != null) buffer.write(', ${(level * 100).round()}%');
    if (conditions.isLowPowerModeEnabled) buffer.write(', low power mode');
    return buffer.toString();
  }
}

/// The same four rows as the Swift demo: one verified model and three size
/// classes known only by file size, at 1024 tokens of context.
class EstimateTable extends StatelessWidget {
  EstimateTable({super.key, required this.report});

  final ProbeReport report;

  static const int contextTokens = 1024;

  final List<ModelSpec> models = [
    ModelSpec.tinyLlama1_1BQ4_0,
    ModelSpec.fromGgufBytes(name: '1.1 GB model', ggufBytes: 1100000000),
    ModelSpec.fromGgufBytes(name: '2.2 GB model', ggufBytes: 2200000000),
    ModelSpec.fromGgufBytes(name: '4.4 GB model', ggufBytes: 4400000000),
  ];

  @override
  Widget build(BuildContext context) {
    final estimates = [
      for (final model in models)
        report.estimate(model, contextTokens: contextTokens),
    ];
    final notes = {for (final estimate in estimates) ...estimate.notes}.toList()
      ..sort();
    return _Card(
      children: [
        Text(
          'Decode estimate at $contextTokens tokens of context',
          style: const TextStyle(fontWeight: FontWeight.w600),
        ),
        const SizedBox(height: 8),
        for (final estimate in estimates) ...[
          Row(
            children: [
              Expanded(child: Text(estimate.model.name)),
              _FitTag(fit: estimate.fit),
            ],
          ),
          _IntervalRow(title: 'peak', interval: estimate.peak),
          if (estimate.sustained case final sustained?)
            _IntervalRow(title: 'sustained', interval: sustained),
          const Divider(height: 16),
        ],
        for (final note in notes)
          Padding(
            padding: const EdgeInsets.only(top: 4),
            child: Text(
              note,
              style: TextStyle(
                fontSize: 12,
                color: Theme.of(context).colorScheme.onSurfaceVariant,
              ),
            ),
          ),
      ],
    );
  }
}

class _Card extends StatelessWidget {
  const _Card({required this.children});

  final List<Widget> children;

  @override
  Widget build(BuildContext context) => Container(
    padding: const EdgeInsets.all(16),
    decoration: BoxDecoration(
      color: Theme.of(context).colorScheme.surfaceContainerHighest,
      borderRadius: BorderRadius.circular(12),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: children,
    ),
  );
}

class _CeilingRow extends StatelessWidget {
  const _CeilingRow({required this.title, required this.figure});

  final String title;
  final BandwidthFigure figure;

  @override
  Widget build(BuildContext context) {
    final ci = figure.medianCI95GBps;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
        Row(
          crossAxisAlignment: CrossAxisAlignment.baseline,
          textBaseline: TextBaseline.alphabetic,
          children: [
            Text(
              '${figure.medianGBps.value.toStringAsFixed(1)} GB/s',
              style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: Text(
                '95% CI ${ci.low.toStringAsFixed(1)}–${ci.high.toStringAsFixed(1)}, '
                'best ${figure.bestGBps.value.toStringAsFixed(1)}',
                style: TextStyle(
                  fontSize: 12,
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
                ),
              ),
            ),
          ],
        ),
        BasisTag(basis: figure.medianGBps.basis),
      ],
    );
  }
}

class _LabelledRow extends StatelessWidget {
  const _LabelledRow({
    required this.title,
    required this.value,
    required this.basis,
  });

  final String title;
  final String value;
  final Basis basis;

  @override
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 2),
    child: Row(
      children: [
        Text(
          title,
          style: TextStyle(
            color: Theme.of(context).colorScheme.onSurfaceVariant,
          ),
        ),
        const Spacer(),
        Text(value),
        const SizedBox(width: 8),
        BasisTag(basis: basis),
      ],
    ),
  );
}

class _IntervalRow extends StatelessWidget {
  const _IntervalRow({required this.title, required this.interval});

  final String title;
  final Interval interval;

  @override
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 2),
    child: Row(
      children: [
        Text(
          title,
          style: TextStyle(
            color: Theme.of(context).colorScheme.onSurfaceVariant,
          ),
        ),
        const Spacer(),
        Text(
          interval.isKnown
              ? '${interval.low.toStringAsFixed(0)}–'
                    '${interval.high.toStringAsFixed(0)} tok/s'
              : 'unknown',
        ),
        const SizedBox(width: 8),
        BasisTag(basis: interval.basis),
      ],
    ),
  );
}

/// The basis is never implied; it is printed next to every figure.
class BasisTag extends StatelessWidget {
  const BasisTag({super.key, required this.basis});

  final Basis basis;

  @override
  Widget build(BuildContext context) {
    final color = switch (basis) {
      MeasuredBasis() => Colors.green,
      CalibratedBasis() => Colors.blue,
      UnknownBasis() => Colors.orange,
    };
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.15),
        borderRadius: BorderRadius.circular(999),
      ),
      child: Text(
        basis.toString(),
        style: TextStyle(fontSize: 11, fontFamily: 'monospace', color: color),
      ),
    );
  }
}

class _FitTag extends StatelessWidget {
  const _FitTag({required this.fit});

  final MemoryFit fit;

  @override
  Widget build(BuildContext context) {
    final (text, color) = switch (fit.verdict) {
      FitsVerdict(:final marginMB) => (
        'fits, ${marginMB.toStringAsFixed(0)} MB spare',
        Colors.green,
      ),
      TightVerdict(:final marginMB) => (
        'tight, ${marginMB.toStringAsFixed(0)} MB spare',
        Colors.orange,
      ),
      DoesNotFitVerdict(:final shortfallMB) => (
        '${shortfallMB.toStringAsFixed(0)} MB short',
        Colors.red,
      ),
    };
    final unknown = fit.basis == Basis.unknown;
    return Text(
      unknown ? '$text (unknown)' : text,
      style: TextStyle(
        fontSize: 12,
        fontFamily: 'monospace',
        color: unknown ? Colors.orange : color,
      ),
    );
  }
}
0
likes
150
points
337
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A two-second on-device probe that tells your app how fast an LLM of a given size will decode on this phone, whether it fits, and on what basis.

Repository (GitHub)
View/report issues

Topics

#llm #on-device-ai #benchmark #memory-bandwidth #inference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on headroom

Packages that implement headroom