Skip to content

Proposal for improving the performances of ChangeNotifier #71900

Closed
@letsar

Description

@letsar

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:

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).

Here are our results:
image

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:

  1. Set the name constant to the implementation for which you want to measure the memory footprint (Initial, Current or Proposed).
  2. Launch the app with this command flutter run --profile lib/main.dart.
  3. Copy the Observatory Uri.
  4. Launch Dart DevTools and connect it to your app with the Observatory Uri you copied before.
  5. Go to the Memory tab.
  6. Wait a couple of seconds and click on GC.
  7. Wait a couple of seconds and click on Take Heap Snapshot
  8. Expand the Snapshot item and copy the Shallow value of External, Filtered, package and src into a spreadsheet
  9. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority issues at the top of the work listc: performanceRelates to speed or footprint issues (see "perf:" labels)c: proposalA detailed proposal for a change to Fluttercustomer: crowdAffects or could affect many people, though not necessarily a specific customer.frameworkflutter/packages/flutter repository. See also f: labels.perf: memoryPerformance issues related to memoryperf: speedPerformance issues related to (mostly rendering) speed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions