Description
Hi 👋
We (@escamoteur, @knaeckeKami, and me, but also with the help of @mraleph and @Kavantix ) found a new implementation for ChangeNotifier
which would improve its performances, especially when notifyListeners
is called.
We created multiple benchmarks to see how our implementation behaves in multiple conditions.
These benchmarks are available here: https://github.com/knaeckeKami/changenotifier_benchmark
We measured the performances in terms of CPU time, but also the Memory Footprint, for 3 different implementations:
- The Initial implementation, which is the
ChangeNotifier
implementation before this PR: Use a LinkedList to improve the performances of ChangeNotifier #62330. - The Current implementation, which is the one available on the flutter master branch as of today.
- The Proposed implementation, which is our implementation.
CPU Time
Our Benchmark
You will find below the results of our main benchmark:
┌───────────────────────────────────────────────────────────────────────────────┐
│ ValueNotifier benchmark │
├─────────────┬─────────────────────┬─────────────────────┬─────────────────────┤
│ │ Initial │ Current │ Proposed │
│ Listeners ├─────────┬───────────┼─────────┬───────────┼─────────┬───────────┤
│ │ Updates │ Time [µs] │ Updates │ Time [µs] │ Updates │ Time [µs] │
├─────────────┼─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10 │ 2 │ 10 │ 1 │ 10 │ 0 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 100 │ 114 │ 100 │ 1 │ 100 │ 1 │
│ 1 ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 1000 │ 82 │ 1000 │ 14 │ 1000 │ 12 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10000 │ 1194 │ 10000 │ 162 │ 10000 │ 225 │
├─────────────┼─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10 │ 6 │ 10 │ 6 │ 10 │ 1 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 100 │ 26 │ 100 │ 14 │ 100 │ 2 │
│ 2 ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 1000 │ 266 │ 1000 │ 146 │ 1000 │ 22 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10000 │ 3346 │ 10000 │ 1459 │ 10000 │ 255 │
├─────────────┼─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10 │ 18 │ 10 │ 3 │ 10 │ 1 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 100 │ 68 │ 100 │ 22 │ 100 │ 3 │
│ 4 ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 1000 │ 743 │ 1000 │ 209 │ 1000 │ 28 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10000 │ 7556 │ 10000 │ 2240 │ 10000 │ 309 │
├─────────────┼─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10 │ 22 │ 10 │ 5 │ 10 │ 1 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 100 │ 153 │ 100 │ 37 │ 100 │ 56 │
│ 8 ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 1000 │ 1557 │ 1000 │ 372 │ 1000 │ 58 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10000 │ 15599 │ 10000 │ 4559 │ 10000 │ 532 │
├─────────────┼─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10 │ 81 │ 10 │ 8 │ 10 │ 1 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 100 │ 320 │ 100 │ 68 │ 100 │ 9 │
│ 16 ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 1000 │ 3238 │ 1000 │ 939 │ 1000 │ 90 │
│ ├─────────┼───────────┼─────────┼───────────┼─────────┼───────────┤
│ │ 10000 │ 31379 │ 10000 │ 8216 │ 10000 │ 1041 │
├─────────────┼─────────┴───────────┼─────────┴───────────┼─────────┴───────────┤
│ Total Time: │ 65770 │ 18481 │ 2647 │
└─────────────┴─────────────────────┴─────────────────────┴─────────────────────┘
The code of this benchmark is the following, and you can find it here.
Future<int> runBenchmark({
required ValueNotifierFactory creator,
required final int updates,
required final int listeners,
}) {
final c = Completer<int>();
final notifier = creator(0);
final timer = Stopwatch()..start();
for (var i = 0; i < listeners - 1; i++) {
notifier.addListener(() {});
}
notifier.addListener(() {
if (updates == notifier.value) {
timer.stop();
c.complete(timer.elapsedMicroseconds);
}
});
for (var i = 0; i <= updates; i++) {
notifier.value = i;
}
return c.future;
}
This benchmark has been run on my OnePlus 8 Pro (with Android 11) with the following command:
flutter run --release lib/benchmark.dart
As you can see, the Proposed implementation seems to be much faster than the previous ones.
Flutter microbenchmark
We also ran the flutter microbenchmark, for ChangeNotifier
, available here in which we made some modifications to be able to test our 3 implementations, with 100,000 iterations for each one.
We normalized the results to see how much other implementations are slower than the fastest (the implementation with a score of 1).
As you can see, the Proposed implementation is the fastest for almost all iterations in this benchmark as well.
Memory Footprint
We also wanted to compare the memory footprint of these different implementations.
We didn't find an automatic way to measure this so we took an approach based on Heap Snapshots with Dart DevTools.
We created a simple app which will instantiate 1000 notifiers with 1000 listeners for each one of them:
const name = 'Proposed';
const notifierCount = 1000;
const listenerCount = 1000;
main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({
Key? key,
}) : super(key: key);
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final List<ValueNotifier<int>> notifiers;
@override
void initState() {
super.initState();
notifiers = List.generate(notifierCount, (index) {
final notifier = factories[name]!(0);
for (var i = 0; i < listenerCount; i++) {
notifier.addListener(() {});
}
return notifier;
});
}
@override
Widget build(BuildContext context) {
return const SizedBox();
}
}
The protocol to measure the memory footprint can be reproduced by following these steps:
- Set the
name
constant to the implementation for which you want to measure the memory footprint (Initial, Current or Proposed). - Launch the app with this command
flutter run --profile lib/main.dart
. - Copy the Observatory Uri.
- Launch Dart DevTools and connect it to your app with the Observatory Uri you copied before.
- Go to the Memory tab.
- Wait a couple of seconds and click on GC.
- Wait a couple of seconds and click on Take Heap Snapshot
- Expand the Snapshot item and copy the Shallow value of External, Filtered, package and src into a spreadsheet
- Go to 1. and change the name.
We also measure the memory footprint of the same app with 0 notifiers to have a reference.
These are our results:
┌──────────┬─────────────┬─────────┬─────────┬──────────┐
│ │ 0 notifiers │ Initial │ Current │ Proposed │
├──────────┼─────────────┼─────────┼─────────┼──────────┤
│ External │ 3.01K │ 3.01K │ 3.01K │ 3.01K │
│ Filtered │ 1,99M │ 74,3M │ 114M │ 74,2M │
│ package │ 16 │ 32K │ 16 │ 64K │
│ src │ 4,64M │ 4,64M │ 4,64M │ 4,64M │
│ Total │ 6.63M │ 78,98M │ 118,64M │ 78,91M │
└──────────┴─────────────┴─────────┴─────────┴──────────┘
We can see that Initial and Proposed implementations have about the same memory footprint, but the Current implementation's footprint is higher.
By removing the total memory footprint of the 0 notifiers iteration, we can compute that the Proposed implementation consumes 1.55 times less than the Current one.
We also think that this proposed implementation allocates fewer temporary objects because it doesn't create a new list for each call to notifyListener
.
Proposed implementation
You will find below the proposed implementation which lays on a custom growable list:
class ProposedChangeNotifier implements Listenable {
int _length = 0;
List<VoidCallback?>? _listeners = List<VoidCallback?>.filled(0, null);
int _notificationCallStackDepth = 0;
int _removedListeners = 0;
bool _debugAssertNotDisposed() {
assert(() {
if (_listeners == null) {
throw FlutterError('A $runtimeType was used after being disposed.\n'
'Once you have called dispose() on a $runtimeType, it can no longer be used.');
}
return true;
}());
return true;
}
bool get hasListeners {
assert(_debugAssertNotDisposed());
return _length > 0;
}
void addListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
if (_length == _listeners!.length) {
if (_length == 0) {
_listeners = List<VoidCallback?>.filled(1, null);
} else {
final newListeners =
List<VoidCallback?>.filled(_listeners!.length * 2, null);
for (int i = 0; i < _length; i++) {
newListeners[i] = _listeners![i];
}
_listeners = newListeners;
}
}
_listeners![_length++] = listener;
}
void _removeAt(int index) {
for (int i = index; i < _length - 1; i++) {
_listeners![i] = _listeners![i + 1];
}
_length--;
}
void removeListener(VoidCallback listener) {
assert(_debugAssertNotDisposed());
for (int i = 0; i < _length; i++) {
final _listener = _listeners![i];
if (_listener == listener) {
if (_notificationCallStackDepth > 0) {
_listeners![i] = null;
_removedListeners++;
} else {
_removeAt(i);
}
break;
}
}
}
void dispose() {
assert(_debugAssertNotDisposed());
_listeners = null;
}
void notifyListeners() {
assert(_debugAssertNotDisposed());
if (_length == 0) {
return;
}
_notificationCallStackDepth++;
final int end = _length;
for (int i = 0; i < end; i++) {
try {
_listeners![i]?.call();
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'foundation library',
context: ErrorDescription(
'while dispatching notifications for $runtimeType'),
informationCollector: () sync* {
yield DiagnosticsProperty<ProposedChangeNotifier>(
'The $runtimeType sending notification was',
this,
style: DiagnosticsTreeStyle.errorProperty,
);
},
));
}
}
_notificationCallStackDepth--;
if (_notificationCallStackDepth == 0 && _removedListeners > 0) {
// We really remove the listeners when all notifications are done.
final newLength = _length - _removedListeners;
final newListeners = List<VoidCallback?>.filled(newLength, null);
int newIndex = 0;
for (int i = 0; i < _length; i++) {
final listener = _listeners![i];
if (listener != null) {
newListeners[newIndex++] = listener;
}
}
_removedListeners = 0;
_length = newLength;
_listeners = newListeners;
}
}
}
This implementation passes all current tests.
The main drawback of this implementation can be the source code which is more complicated than the previous ones.
But the benchmark results show a lot of benefits and we think that it could be a good opportunity to change the current implementation to this one.
If you think that's a good idea, we can do a PR ourselves and also add some tests to ChangeNotifier
.