Skip to content

Commit e3a6bf9

Browse files
pkozlowski-opensourcealxhub
authored andcommitted
perf(core): optimize memory allocation when reconcilling lists (#52245)
This change replaces the implementation of the multi-map used to store detached views while reconciling lists. The new implementation optimizes memory allocation for such map and avoid arrays allocation when there are no duplicated keys. PR Close #52245
1 parent f2245d1 commit e3a6bf9

File tree

3 files changed

+140
-38
lines changed

3 files changed

+140
-38
lines changed

packages/core/src/render3/list_reconciliation.ts

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {TrackByFunction} from '../change_detection';
10+
import {assertNotSame} from '../util/assert';
1011

1112
/**
1213
* A type representing the live collection to be reconciled with any new (incoming) collection. This
@@ -86,7 +87,7 @@ function valuesMatching<V>(
8687
export function reconcile<T, V>(
8788
liveCollection: LiveCollection<T, V>, newCollection: Iterable<V>|undefined|null,
8889
trackByFn: TrackByFunction<V>): void {
89-
let detachedItems: MultiMap<unknown, T>|undefined = undefined;
90+
let detachedItems: UniqueValueMultiKeyMap<unknown, T>|undefined = undefined;
9091
let liveKeysInTheFuture: Set<unknown>|undefined = undefined;
9192

9293
let liveStartIdx = 0;
@@ -148,7 +149,7 @@ export function reconcile<T, V>(
148149

149150
// Fallback to the slow path: we need to learn more about the content of the live and new
150151
// collections.
151-
detachedItems ??= new MultiMap();
152+
detachedItems ??= new UniqueValueMultiKeyMap();
152153
liveKeysInTheFuture ??=
153154
initLiveItemsInTheFuture(liveCollection, liveStartIdx, liveEndIdx, trackByFn);
154155

@@ -197,7 +198,7 @@ export function reconcile<T, V>(
197198
liveStartIdx++;
198199
newIterationResult = newCollectionIterator.next();
199200
} else {
200-
detachedItems ??= new MultiMap();
201+
detachedItems ??= new UniqueValueMultiKeyMap();
201202
liveKeysInTheFuture ??=
202203
initLiveItemsInTheFuture(liveCollection, liveStartIdx, liveEndIdx, trackByFn);
203204

@@ -246,8 +247,9 @@ export function reconcile<T, V>(
246247
}
247248

248249
function attachPreviouslyDetached<T, V>(
249-
prevCollection: LiveCollection<T, V>, detachedItems: MultiMap<unknown, T>|undefined,
250-
index: number, key: unknown): boolean {
250+
prevCollection: LiveCollection<T, V>,
251+
detachedItems: UniqueValueMultiKeyMap<unknown, T>|undefined, index: number,
252+
key: unknown): boolean {
251253
if (detachedItems !== undefined && detachedItems.has(key)) {
252254
prevCollection.attach(index, detachedItems.get(key)!);
253255
detachedItems.delete(key);
@@ -257,7 +259,8 @@ function attachPreviouslyDetached<T, V>(
257259
}
258260

259261
function createOrAttach<T, V>(
260-
liveCollection: LiveCollection<T, V>, detachedItems: MultiMap<unknown, T>|undefined,
262+
liveCollection: LiveCollection<T, V>,
263+
detachedItems: UniqueValueMultiKeyMap<unknown, T>|undefined,
261264
trackByFn: TrackByFunction<unknown>, index: number, value: V) {
262265
if (!attachPreviouslyDetached(liveCollection, detachedItems, index, trackByFn(index, value))) {
263266
const newItem = liveCollection.create(index, value);
@@ -277,43 +280,78 @@ function initLiveItemsInTheFuture<T>(
277280
return keys;
278281
}
279282

280-
class MultiMap<K, V> {
281-
private map = new Map<K, Array<V>>();
283+
/**
284+
* A specific, partial implementation of the Map interface with the following characteristics:
285+
* - allows multiple values for a given key;
286+
* - maintain FIFO order for multiple values corresponding to a given key;
287+
* - assumes that all values are unique.
288+
*
289+
* The implementation aims at having the minimal overhead for cases where keys are _not_ duplicated
290+
* (the most common case in the list reconciliation algorithm). To achieve this, the first value for
291+
* a given key is stored in a regular map. Then, when more values are set for a given key, we
292+
* maintain a form of linked list in a separate map. To maintain this linked list we assume that all
293+
* values (in the entire collection) are unique.
294+
*/
295+
export class UniqueValueMultiKeyMap<K, V> {
296+
// A map from a key to the first value corresponding to this key.
297+
private kvMap = new Map<K, V>();
298+
// A map that acts as a linked list of values - each value maps to the next value in this "linked
299+
// list" (this only works if values are unique). Allocated lazily to avoid memory consumption when
300+
// there are no duplicated values.
301+
private _vMap: Map<V, V>|undefined = undefined;
282302

283303
has(key: K): boolean {
284-
const listOfKeys = this.map.get(key);
285-
return listOfKeys !== undefined && listOfKeys.length > 0;
304+
return this.kvMap.has(key);
286305
}
287306

288307
delete(key: K): boolean {
289-
const listOfKeys = this.map.get(key);
290-
if (listOfKeys !== undefined) {
291-
listOfKeys.shift();
292-
return true;
308+
if (!this.has(key)) return false;
309+
310+
const value = this.kvMap.get(key)!;
311+
if (this._vMap !== undefined && this._vMap.has(value)) {
312+
this.kvMap.set(key, this._vMap.get(value)!);
313+
this._vMap.delete(value);
314+
} else {
315+
this.kvMap.delete(key);
293316
}
294-
return false;
317+
318+
return true;
295319
}
296320

297321
get(key: K): V|undefined {
298-
const listOfKeys = this.map.get(key);
299-
return listOfKeys !== undefined && listOfKeys.length > 0 ? listOfKeys[0] : undefined;
322+
return this.kvMap.get(key);
300323
}
301324

302325
set(key: K, value: V): void {
303-
// if value is array, they we always store it as [value].
304-
if (!this.map.has(key)) {
305-
this.map.set(key, [value]);
306-
return;
326+
if (this.kvMap.has(key)) {
327+
let prevValue = this.kvMap.get(key)!;
328+
ngDevMode &&
329+
assertNotSame(
330+
prevValue, value, `Detected a duplicated value ${value} for the key ${key}`);
331+
332+
if (this._vMap === undefined) {
333+
this._vMap = new Map();
334+
}
335+
336+
const vMap = this._vMap;
337+
while (vMap.has(prevValue)) {
338+
prevValue = vMap.get(prevValue)!;
339+
}
340+
vMap.set(prevValue, value);
341+
} else {
342+
this.kvMap.set(key, value);
307343
}
308-
// THINK: this allows duplicate values, but I guess this is fine?
309-
// Is the existing key an array or not?
310-
this.map.get(key)?.push(value);
311344
}
312345

313346
forEach(cb: (v: V, k: K) => void) {
314-
for (const [key, values] of this.map) {
315-
for (const value of values) {
316-
cb(value, key);
347+
for (let [key, value] of this.kvMap) {
348+
cb(value, key);
349+
if (this._vMap !== undefined) {
350+
const vMap = this._vMap;
351+
while (vMap.has(value)) {
352+
value = vMap.get(value)!;
353+
cb(value, key);
354+
}
317355
}
318356
}
319357
}

packages/core/test/render3/list_reconciliation_spec.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -261,19 +261,21 @@ describe('list reconciliation', () => {
261261
});
262262

263263
it('should create / reuse duplicated items as needed', () => {
264-
const pc = new LoggingLiveCollection([1, 1, 2, 3]);
265-
reconcile(pc, [2, 3, 1, 1, 1, 4], trackByIdentity);
264+
const trackByKey = (idx: number, item: {k: number}) => item.k;
265+
const pc =
266+
new LoggingLiveCollection<{k: number}, {k: number}>([{k: 1}, {k: 1}, {k: 2}, {k: 3}]);
267+
reconcile(pc, [{k: 2}, {k: 3}, {k: 1}, {k: 1}, {k: 1}, {k: 4}], trackByKey);
266268

267-
expect(pc.getCollection()).toEqual([2, 3, 1, 1, 1, 4]);
269+
expect(pc.getCollection()).toEqual([{k: 2}, {k: 3}, {k: 1}, {k: 1}, {k: 1}, {k: 4}]);
268270
expect(pc.getLogs()).toEqual([
269-
['detach', 0, 1],
270-
['detach', 0, 1],
271-
['attach', 2, 1],
272-
['attach', 3, 1],
273-
['create', 4, 1],
274-
['attach', 4, 1],
275-
['create', 5, 4],
276-
['attach', 5, 4],
271+
['detach', 0, {k: 1}],
272+
['detach', 0, {k: 1}],
273+
['attach', 2, {k: 1}],
274+
['attach', 3, {k: 1}],
275+
['create', 4, {k: 1}],
276+
['attach', 4, {k: 1}],
277+
['create', 5, {k: 4}],
278+
['attach', 5, {k: 4}],
277279
]);
278280
});
279281
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {UniqueValueMultiKeyMap} from '@angular/core/src/render3/list_reconciliation';
10+
11+
describe('MultiMap', () => {
12+
it('should set, get and remove items with duplicated keys', () => {
13+
const map = new UniqueValueMultiKeyMap();
14+
15+
map.set('k', 'v1');
16+
map.set('k', 'v2');
17+
18+
expect(map.has('k')).toBeTrue();
19+
expect(map.get('k')).toBe('v1');
20+
21+
map.delete('k');
22+
expect(map.has('k')).toBeTrue();
23+
expect(map.get('k')).toBe('v2');
24+
25+
map.delete('k');
26+
expect(map.has('k')).toBeFalse();
27+
});
28+
29+
it('should set, get and remove items without duplicated keys', () => {
30+
const map = new UniqueValueMultiKeyMap();
31+
32+
map.set('k', 'v1');
33+
34+
expect(map.has('k')).toBeTrue();
35+
expect(map.get('k')).toBe('v1');
36+
37+
map.delete('k');
38+
expect(map.has('k')).toBeFalse();
39+
});
40+
41+
it('should iterate with forEach', () => {
42+
const map = new UniqueValueMultiKeyMap<string, string>();
43+
44+
map.set('km', 'v1');
45+
map.set('km', 'v2');
46+
map.set('ks', 'v');
47+
48+
const items: string[][] = [];
49+
50+
map.forEach((v, k) => items.push([v, k]));
51+
expect(items).toEqual([['v1', 'km'], ['v2', 'km'], ['v', 'ks']]);
52+
});
53+
54+
it('should throw upon detecting duplicate values', () => {
55+
const map = new UniqueValueMultiKeyMap();
56+
57+
map.set('k', 'v');
58+
expect(() => {
59+
map.set('k', 'v');
60+
}).toThrowError(/Detected a duplicated value v for the key k/);
61+
});
62+
});

0 commit comments

Comments
 (0)