Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
200 changes: 200 additions & 0 deletions benchmark/grid_perf_bench.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// ignore_for_file: depend_on_referenced_packages

// Frame-timing benchmark for VirtualResultGrid. Run in profile mode:
// flutter run --profile -d linux -t benchmark/grid_perf_bench.dart --dart-define=MODE=scroll
// MODE: scroll (horizontal pan) | edit (staged cell edits on a sorted grid)
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' as material;
import 'package:flutter/scheduler.dart';
import 'package:querya_desktop/core/theme/querya_theme.dart';
import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart';
import 'package:querya_desktop/features/workspace/result_grid_view.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:vm_service/vm_service.dart' as vms;
import 'package:vm_service/vm_service_io.dart' as vms_io;

const mode = String.fromEnvironment('MODE', defaultValue: 'scroll');
const rowCount = int.fromEnvironment('ROWS', defaultValue: 5000);
const colCount = int.fromEnvironment('COLS', defaultValue: 120);
const seconds = int.fromEnvironment('SECS', defaultValue: 6);
const profile = bool.fromEnvironment('PROFILE', defaultValue: false);

vms.VmService? _vm;
String? _isolateId;

Future<void> _connectVm() async {
final info = await developer.Service.getInfo();
final uri = info.serverUri;
if (uri == null) return;
final ws = uri.replace(scheme: 'ws', path: '${uri.path}ws').toString();
_vm = await vms_io.vmServiceConnectUri(ws);
_isolateId = developer.Service.getIsolateId(Isolate.current);
}


final timings = <FrameTiming>[];

void main() {
WidgetsFlutterBinding.ensureInitialized();
SchedulerBinding.instance.addTimingsCallback(timings.addAll);
final columns = [for (var c = 0; c < colCount; c++) 'col_$c'];
final rows = [
for (var r = 0; r < rowCount; r++)
[for (var c = 0; c < colCount; c++) c == 0 ? '${(r * 7919) % rowCount}' : 'v${r}_$c'],
];
final buffer = DataGridStagingBuffer(columns: columns, rows: rows);
final td = QueryaTheme.darkDefault
.toShadcnThemeData()
.copyWith(platform: () => TargetPlatform.linux);
runApp(ShadcnApp(
theme: td,
home: material.Scaffold(
body: ListenableBuilder(
listenable: buffer,
builder: (_, __) => VirtualResultGrid(
columns: columns,
rows: buffer.effectiveRows,
stagingBuffer: buffer,
),
),
),
));
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future<void>.delayed(const Duration(seconds: 2));
await _run(buffer);
});
}

Element? _find(bool Function(Element) test) {
Element? found;
void visit(Element e) {
if (found != null) return;
if (test(e)) {
found = e;
return;
}
e.visitChildren(visit);
}
WidgetsBinding.instance.rootElement!.visitChildren(visit);
return found;
}

Future<void> _tapText(String text) async {
final e = _find((e) => e.widget is Text && (e.widget as Text).data == text);
final box = e!.renderObject! as RenderBox;
final pos = box.localToGlobal(box.size.center(Offset.zero));
final b = GestureBinding.instance;
b.handlePointerEvent(PointerDownEvent(position: pos, pointer: 99));
b.handlePointerEvent(PointerUpEvent(position: pos, pointer: 99));
await Future<void>.delayed(const Duration(milliseconds: 500));
}

Future<void> _run(DataGridStagingBuffer buffer) async {
if (mode == 'edit') await _tapText('col_0'); // sort ascending
if (profile) {
await _connectVm();
await _vm?.clearCpuSamples(_isolateId!);
}
timings.clear();
final startMicros = DateTime.now().microsecondsSinceEpoch;
final end = DateTime.now().add(const Duration(seconds: seconds));
var i = 0;
ScrollPosition? pos;
if (mode == 'scroll') {
final e = _find((e) =>
e.widget is Scrollable && (e.widget as Scrollable).axis == Axis.horizontal);
pos = (e as StatefulElement).state is ScrollableState
? ((e.state) as ScrollableState).position
: null;
}
var dir = 1.0;
while (DateTime.now().isBefore(end)) {
await SchedulerBinding.instance.endOfFrame;
if (mode == 'scroll') {
final p = pos!;
final next = p.pixels + dir * 14;
if (next > p.maxScrollExtent || next < 0) dir = -dir;
p.jumpTo((p.pixels + dir * 14).clamp(0.0, p.maxScrollExtent));
} else {
buffer.setCell((i * 37) % rowCount, 1 + i % 5, 'edit$i');
i++;
}
}
await Future<void>.delayed(const Duration(seconds: 1));
_report();
if (profile) await _reportCpu(startMicros);
exit(0);
}

void _report() {
double ms(Duration d) => d.inMicroseconds / 1000.0;
double pct(List<double> v, double p) {
final s = [...v]..sort();
return s[((s.length - 1) * p).round()];
}
final build = [for (final t in timings) ms(t.buildDuration)];
final raster = [for (final t in timings) ms(t.rasterDuration)];
final total = [for (final t in timings) ms(t.totalSpan)];
const budget = 1000 / 120;
int over(List<double> v) => v.where((x) => x > budget).length;
String row(String n, List<double> v) =>
'$n p50=${pct(v, .5).toStringAsFixed(2)} p90=${pct(v, .9).toStringAsFixed(2)} '
'p99=${pct(v, .99).toStringAsFixed(2)} max=${pct(v, 1).toStringAsFixed(2)} '
'over8.33ms=${over(v)}/${v.length}';
final view = PlatformDispatcher.instance.views.first;
stdout.writeln('BENCH window=${view.physicalSize.width.toInt()}x${view.physicalSize.height.toInt()} dpr=${view.devicePixelRatio}');
stdout.writeln('BENCH mode=$mode rows=$rowCount cols=$colCount frames=${timings.length}');
stdout.writeln('BENCH ${row('build ', build)}');
stdout.writeln('BENCH ${row('raster', raster)}');
stdout.writeln('BENCH ${row('total ', total)}');
}

Future<void> _reportCpu(int startMicros) async {
final vm = _vm;
if (vm == null) {
stdout.writeln('PROFILE no VM service');
return;
}
final samples = await vm.getCpuSamples(_isolateId!, 0, 0x7fffffffffff);
final self = <int, int>{};
final incl = <int, int>{};
for (final s in samples.samples ?? const <vms.CpuSample>[]) {
final stack = s.stack ?? const <int>[];
if (stack.isEmpty) continue;
self.update(stack.first, (v) => v + 1, ifAbsent: () => 1);
for (final f in stack.toSet()) {
incl.update(f, (v) => v + 1, ifAbsent: () => 1);
}
}
final funcs = samples.functions ?? const <vms.ProfileFunction>[];
String name(int i) {
final f = funcs[i].function;
if (f is vms.FuncRef) {
final owner = f.owner;
final o = owner is vms.ClassRef
? '${owner.name}.'
: owner is vms.LibraryRef
? '${owner.name?.split('.').last}::'
: '';
return '$o${f.name}';
}
return '${funcs[i].function}';
}
final total = samples.sampleCount ?? 0;
void top(String title, Map<int, int> m) {
final e = m.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
stdout.writeln('PROFILE == $title (samples=$total) ==');
for (final x in e.take(30)) {
stdout.writeln(
'PROFILE ${(100 * x.value / total).toStringAsFixed(1).padLeft(5)}% ${name(x.key)}');
}
}
top('self time', self);
top('inclusive time', incl);
}
11 changes: 11 additions & 0 deletions lib/features/workspace/data_grid_staging_buffer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,17 @@ class DataGridStagingBuffer extends ChangeNotifier {
return StagedCellStatus.clean;
}

/// Fingerprint of the staged state that changes how [row] is drawn (row
/// status and which cells are modified). Lets a grid skip rebuilding rows
/// whose staged state is unchanged.
int rowRenderSignature(int row) {
final mods = row >= 0 ? _modifiedCells[row] : null;
return Object.hash(
getRowStatus(row),
mods == null || mods.isEmpty ? 0 : Object.hashAllUnordered(mods.keys),
);
}

/// Explicitly sets the cell to SQL NULL.
void setCellNull(int row, int col) {
setCell(row, col, TableMutationEngine.kNullSentinel);
Expand Down
Loading
Loading