From a679c3dcf7c7968a47b513974732bd563f79159f Mon Sep 17 00:00:00 2001 From: Zemlianikin Max Date: Thu, 21 Nov 2024 10:53:06 +0700 Subject: [PATCH] perf: improve equality comparison performance (#173) Co-authored-by: Felix Angelov --- benchmarks/README.md | 154 ++++++++++++++----- benchmarks/pubspec.yaml | 2 +- lib/src/equatable.dart | 2 +- lib/src/equatable_mixin.dart | 2 +- lib/src/equatable_utils.dart | 68 ++++++--- test/equatable_utils_test.dart | 266 +++++++++++++++++++++++++++++++++ 6 files changed, 434 insertions(+), 60 deletions(-) create mode 100644 test/equatable_utils_test.dart diff --git a/benchmarks/README.md b/benchmarks/README.md index 474db23c..fe90cb04 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -11,82 +11,160 @@ Benchmarks used to measure the performance of equality comparisons using `packag ## Results +**JIT** + ``` EmptyEquatable - total runs: 2 064 037 + total runs: 2 729 471 total time: 2.0000 s average run: 0 μs runs/second: Infinity - units: 100 + units: 100 units/second: Infinity time per unit: 0.0000 μs PrimitiveEquatable - total runs: 729 555 + total runs: 669 972 total time: 2.0000 s average run: 2 μs - runs/second: 500 000 - units: 100 - units/second: 50 000 000 + runs/second: 500 000 + units: 100 + units/second: 50 000 000 time per unit: 0.0200 μs CollectionEquatable (static, small) - total runs: 51 944 + total runs: 144 932 total time: 2.0000 s - average run: 38 μs - runs/second: 26 316 - units: 100 - units/second: 2 631 579 - time per unit: 0.3800 μs + average run: 13 μs + runs/second: 76 923 + units: 100 + units/second: 7 692 308 + time per unit: 0.1300 μs CollectionEquatable (static, medium) - total runs: 44 572 + total runs: 84 533 total time: 2.0000 s - average run: 44 μs - runs/second: 22 727 - units: 100 - units/second: 2 272 727 - time per unit: 0.4400 μs + average run: 23 μs + runs/second: 43 478 + units: 100 + units/second: 4 347 826 + time per unit: 0.2300 μs CollectionEquatable (static, large) - total runs: 21 027 + total runs: 16 457 total time: 2.0001 s - average run: 95 μs - runs/second: 10 526 - units: 100 - units/second: 1 052 632 - time per unit: 0.9500 μs + average run: 121 μs + runs/second: 8 264.5 + units: 100 + units/second: 826 446 + time per unit: 1.2100 μs + +CollectionEquatable (dynamic, small) + total runs: 388 236 + total time: 2.0000 s + average run: 5 μs + runs/second: 200 000 + units: 100 + units/second: 20 000 000 + time per unit: 0.0500 μs + +CollectionEquatable (dynamic, medium) + total runs: 382 155 + total time: 2.0000 s + average run: 5 μs + runs/second: 200 000 + units: 100 + units/second: 20 000 000 + time per unit: 0.0500 μs + +CollectionEquatable (dynamic, large) + total runs: 390 713 + total time: 2.0000 s + average run: 5 μs + runs/second: 200 000 + units: 100 + units/second: 20 000 000 + time per unit: 0.0500 μs +``` + +**AOT** + +``` +EmptyEquatable + total runs: 1 615 534 + total time: 2.0000 s + average run: 1 μs + runs/second: 1 000 000 + units: 100 + units/second: 100 000 000 + time per unit: 0.0100 μs + +PrimitiveEquatable + total runs: 928 013 + total time: 2.0000 s + average run: 2 μs + runs/second: 500 000 + units: 100 + units/second: 50 000 000 + time per unit: 0.0200 μs + +CollectionEquatable (static, small) + total runs: 128 224 + total time: 2.0000 s + average run: 15 μs + runs/second: 66 667 + units: 100 + units/second: 6 666 667 + time per unit: 0.1500 μs + +CollectionEquatable (static, medium) + total runs: 104 624 + total time: 2.0000 s + average run: 19 μs + runs/second: 52 632 + units: 100 + units/second: 5 263 158 + time per unit: 0.1900 μs + +CollectionEquatable (static, large) + total runs: 33 653 + total time: 2.0000 s + average run: 59 μs + runs/second: 16 949 + units: 100 + units/second: 1 694 915 + time per unit: 0.5900 μs CollectionEquatable (dynamic, small) - total runs: 400 934 + total runs: 483 177 total time: 2.0000 s average run: 4 μs - runs/second: 250 000 - units: 100 - units/second: 25 000 000 + runs/second: 250 000 + units: 100 + units/second: 25 000 000 time per unit: 0.0400 μs CollectionEquatable (dynamic, medium) - total runs: 400 408 + total runs: 488 550 total time: 2.0000 s average run: 4 μs - runs/second: 250 000 - units: 100 - units/second: 25 000 000 + runs/second: 250 000 + units: 100 + units/second: 25 000 000 time per unit: 0.0400 μs CollectionEquatable (dynamic, large) - total runs: 400 966 + total runs: 494 041 total time: 2.0000 s average run: 4 μs - runs/second: 250 000 - units: 100 - units/second: 25 000 000 + runs/second: 250 000 + units: 100 + units/second: 25 000 000 time per unit: 0.0400 μs ``` -_Last Updated: June 3, 2024 using `725b76c9ef072695f3ae4f036c4fa5e015528f13`_ +_Last Updated: November 20, 2024 using `29e6c77a2e6b25e35cce66276bc2afeab1c805bd`_ _MacBook Pro (M1 Pro, 16GB RAM)_ -Dart SDK version: 3.5.0-218.0.dev (dev) (Mon Jun 3 13:02:57 2024 -0700) on "macos_arm64" +Dart SDK version: Dart SDK version: 3.5.4 (stable) (Wed Oct 16 16:18:51 2024 +0000) on "macos_arm64" diff --git a/benchmarks/pubspec.yaml b/benchmarks/pubspec.yaml index 380774da..4ab5d9e8 100644 --- a/benchmarks/pubspec.yaml +++ b/benchmarks/pubspec.yaml @@ -2,7 +2,7 @@ name: equatable_benchmarks publish_to: none environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=3.5.0 <4.0.0" dependencies: equatable: ^2.0.0 diff --git a/lib/src/equatable.dart b/lib/src/equatable.dart index a23801f2..6b4445b9 100644 --- a/lib/src/equatable.dart +++ b/lib/src/equatable.dart @@ -46,7 +46,7 @@ abstract class Equatable { return identical(this, other) || other is Equatable && runtimeType == other.runtimeType && - equals(props, other.props); + iterableEquals(props, other.props); } @override diff --git a/lib/src/equatable_mixin.dart b/lib/src/equatable_mixin.dart index b4d376d9..b69db159 100644 --- a/lib/src/equatable_mixin.dart +++ b/lib/src/equatable_mixin.dart @@ -22,7 +22,7 @@ mixin EquatableMixin { return identical(this, other) || other is EquatableMixin && runtimeType == other.runtimeType && - equals(props, other.props); + iterableEquals(props, other.props); } @override diff --git a/lib/src/equatable_utils.dart b/lib/src/equatable_utils.dart index 301d81fb..615b0020 100644 --- a/lib/src/equatable_utils.dart +++ b/lib/src/equatable_utils.dart @@ -6,32 +6,62 @@ int mapPropsToHashCode(Iterable? props) { return _finish(props == null ? 0 : props.fold(0, _combine)); } -const DeepCollectionEquality _equality = DeepCollectionEquality(); +/// Determines whether two iterables are equal. +@pragma('vm:prefer-inline') +bool iterableEquals(Iterable a, Iterable b) { + assert( + a is! Set && b is! Set, + "iterableEquals doesn't support Sets. Use setEquals instead.", + ); + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!objectsEquals(a.elementAt(i), b.elementAt(i))) return false; + } + return true; +} -/// Determines whether [list1] and [list2] are equal. -bool equals(List? list1, List? list2) { - if (identical(list1, list2)) return true; - if (list1 == null || list2 == null) return false; - final length = list1.length; - if (length != list2.length) return false; +/// Determines whether two sets are equal. +bool setEquals(Set a, Set b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final element in a) { + if (!b.any((e) => objectsEquals(element, e))) return false; + } + return true; +} - for (var i = 0; i < length; i++) { - final unit1 = list1[i]; - final unit2 = list2[i]; +/// Determines whether two maps are equal. +bool mapEquals(Map a, Map b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!objectsEquals(a[key], b[key])) return false; + } + return true; +} - if (_isEquatable(unit1) && _isEquatable(unit2)) { - if (unit1 != unit2) return false; - } else if (unit1 is Iterable || unit1 is Map) { - if (!_equality.equals(unit1, unit2)) return false; - } else if (unit1?.runtimeType != unit2?.runtimeType) { - return false; - } else if (unit1 != unit2) { - return false; - } +/// Determines whether two objects are equal. +@pragma('vm:prefer-inline') +bool objectsEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (_isEquatable(a) && _isEquatable(b)) { + return a == b; + } else if (a is Set && b is Set) { + return setEquals(a, b); + } else if (a is Iterable && b is Iterable) { + return iterableEquals(a, b); + } else if (a is Map && b is Map) { + return mapEquals(a, b); + } else if (a?.runtimeType != b?.runtimeType) { + return false; + } else if (a != b) { + return false; } return true; } +@pragma('vm:prefer-inline') bool _isEquatable(Object? object) { return object is Equatable || object is EquatableMixin; } diff --git a/test/equatable_utils_test.dart b/test/equatable_utils_test.dart new file mode 100644 index 00000000..bea9f886 --- /dev/null +++ b/test/equatable_utils_test.dart @@ -0,0 +1,266 @@ +import 'package:equatable/equatable.dart'; +import 'package:equatable/src/equatable_utils.dart'; +import 'package:test/test.dart'; + +class Person with EquatableMixin { + Person({required this.name}); + + final String name; + + @override + List get props => [name]; +} + +void main() { + final bob = Person(name: 'Bob'); + final alice = Person(name: 'Alice'); + final aliceCopy = Person(name: 'Alice'); + + group('iterableEquals', () { + test('returns true for identical props', () { + final value = [Object()]; + expect(iterableEquals(value, value), isTrue); + }); + + test('returns true for empty iterables', () { + expect(iterableEquals([], []), isTrue); + }); + + test('returns false when props differ in length', () { + final object = Object(); + expect(iterableEquals([object], [object, object]), isFalse); + }); + + test('uses == when props are equatable', () { + expect(iterableEquals([alice], [aliceCopy]), isTrue); + expect(iterableEquals([bob], [bob]), isTrue); + expect(iterableEquals([alice], [bob]), isFalse); + expect(iterableEquals([bob], [alice]), isFalse); + expect(iterableEquals([alice, null], [alice, -1]), isFalse); + }); + + test('returns false for iterables with different elements', () { + final iterable1 = [1, 2, 3]; + final iterable2 = [1, 2, 4]; + expect(iterableEquals(iterable1, iterable2), isFalse); + }); + + test( + 'returns false for iterable with same elements ' + 'but different order', () { + final iterable1 = [1, 2, 3]; + final iterable2 = [1, 3, 2]; + expect(iterableEquals(iterable1, iterable2), isFalse); + }); + + test('returns true for nested identical iterables', () { + final iterable1 = [ + [bob, alice], + [alice, bob], + ]; + final iterable2 = [ + [bob, alice], + [alice, bob], + ]; + expect(iterableEquals(iterable1, iterable2), isTrue); + }); + + test('returns false for nested iterables with different elements', () { + final iterable1 = [ + [bob, 2], + [3, 4], + ]; + final iterable2 = [ + [bob, 2], + [3, 5], + ]; + expect(iterableEquals(iterable1, iterable2), isFalse); + }); + }); + + group('setEquals', () { + test('returns true for identical sets', () { + final set1 = {1, 2, 3}; + final set2 = {1, 2, 3}; + expect(setEquals(set1, set2), isTrue); + }); + + test('returns true for identical sets with elements in different order', + () { + final set1 = {1, 3, 2}; + final set2 = {1, 2, 3}; + expect(setEquals(set1, set2), isTrue); + }); + + test('returns false for sets of different lengths', () { + final set1 = {1, 2, 3}; + final set2 = {1, 2}; + expect(setEquals(set1, set2), isFalse); + }); + + test('returns false for sets with different elements', () { + final set1 = {1, 2, 3}; + final set2 = {1, 2, 4}; + expect(setEquals(set1, set2), isFalse); + }); + + test('uses == when props are equatable', () { + expect(setEquals({alice}, {aliceCopy}), isTrue); + expect(setEquals({bob}, {bob}), isTrue); + expect(setEquals({alice}, {bob}), isFalse); + expect(setEquals({bob}, {alice}), isFalse); + expect(setEquals({alice, null}, {alice, -1}), isFalse); + }); + + test('returns true for nested identical sets', () { + final set1 = { + {alice, bob}, + {alice, bob}, + }; + final set2 = { + {alice, bob}, + {alice, bob}, + }; + expect(setEquals(set1, set2), isTrue); + }); + + test('returns false for nested sets with different elements', () { + final set1 = { + {bob, 2}, + {3, 4}, + }; + final set2 = { + {bob, 2}, + {3, 5}, + }; + expect(setEquals(set1, set2), isFalse); + }); + + test('returns true for empty sets', () { + expect(setEquals({}, {}), isTrue); + }); + + test('returns false for sets with different types', () { + final set1 = {1, '2', 3}; + final set2 = {1, 2, 3}; + expect(setEquals(set1, set2), isFalse); + }); + }); + + group('mapEquals', () { + test('returns true for identical maps', () { + final map1 = {'a': 1, 'b': 2, 'c': 3}; + final map2 = {'a': 1, 'b': 2, 'c': 3}; + expect(mapEquals(map1, map2), isTrue); + }); + + test( + 'returns true for identical maps with elements in different order', + () { + final map1 = {'a': 1, 'c': 3, 'b': 2}; + final map2 = {'a': 1, 'b': 2, 'c': 3}; + expect(mapEquals(map1, map2), isTrue); + }, + ); + + test('uses == when props are equatable', () { + expect(mapEquals({'a': alice}, {'a': aliceCopy}), isTrue); + expect(mapEquals({alice: 'a'}, {aliceCopy: 'a'}), isTrue); + expect(mapEquals({'a': bob}, {'a': bob}), isTrue); + }); + + test('returns false for maps of different lengths', () { + final map1 = {'a': 1, 'b': 2, 'c': 3}; + final map2 = {'a': 1, 'b': 2}; + expect(mapEquals(map1, map2), isFalse); + }); + + test('returns false for maps with different keys', () { + final map1 = {'a': 1, 'b': 2, 'c': 3}; + final map2 = {'a': 1, 'b': 2, 'd': 3}; + expect(mapEquals(map1, map2), isFalse); + }); + + test('returns false for maps with different values', () { + final map1 = {'a': 1, 'b': 2, 'c': 3}; + final map2 = {'a': 1, 'b': 2, 'c': 4}; + expect(mapEquals(map1, map2), isFalse); + }); + + test('returns true for nested identical maps', () { + final map1 = { + 'a': {'x': 1, 'y': 2}, + 'b': {'z': 3}, + }; + final map2 = { + 'a': {'x': 1, 'y': 2}, + 'b': {'z': 3}, + }; + expect(mapEquals(map1, map2), isTrue); + }); + + test('returns false for nested maps with different elements', () { + final map1 = { + 'a': {'x': 1, 'y': 2}, + 'b': {'z': 3}, + }; + final map2 = { + 'a': {'x': 1, 'y': 2}, + 'b': {'z': 4}, + }; + expect(mapEquals(map1, map2), isFalse); + }); + + test('returns true for empty maps', () { + expect(mapEquals({}, {}), isTrue); + }); + + test('returns false for maps with same keys but different values', () { + final map1 = {'a': 1, 'b': '2', 'c': 3}; + final map2 = {'a': 1, 'b': 2, 'c': 3}; + expect(mapEquals(map1, map2), isFalse); + }); + }); + + group('objectsEquals', () { + test('returns true for identical objects', () { + final object = Object(); + expect(objectsEquals(object, object), isTrue); + }); + + test('returns true for same objects', () { + const object1 = 'object'; + // ignore: prefer_const_declarations + final object2 = 'object'; + expect(objectsEquals(object1, object2), isTrue); + }); + + test('returns true for equatable objects', () { + expect(objectsEquals(alice, aliceCopy), isTrue); + expect(objectsEquals(bob, bob), isTrue); + }); + + test('returns false for different objects', () { + expect(objectsEquals(alice, bob), isFalse); + expect(objectsEquals(bob, alice), isFalse); + }); + + test('returns true for same lists', () { + expect(objectsEquals([1, 2, 3], [1, 2, 3]), isTrue); + }); + + test('returns true for same sets', () { + expect(objectsEquals({1, 2, 3}, {1, 2, 3}), isTrue); + expect(objectsEquals({1, 3, 2}, {1, 2, 3}), isTrue); + }); + + test('returns true for same maps', () { + expect(objectsEquals({'a': 1, 'b': 2}, {'a': 1, 'b': 2}), isTrue); + expect(objectsEquals({'c': 3, 'b': 2}, {'b': 2, 'c': 3}), isTrue); + }); + + test('returns false for different types', () { + expect(objectsEquals(1, '1'), isFalse); + }); + }); +}