Skip to content

[WIP][stdlib] Dictionary: Add unsafe bulk-loading initializer #21208

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
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
131 changes: 131 additions & 0 deletions stdlib/public/core/DictionaryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,134 @@ struct _DictionaryBuilder<Key: Hashable, Value> {
return Dictionary(_native: _target)
}
}

extension Dictionary {
/// Creates a new dictionary with the specified capacity, then calls the given
/// closure to initialize its contents.
///
/// Foundation uses this initializer to bridge the contents of an NSDictionary
/// instance without allocating a pair of intermediary buffers. Pass the
/// required capacity and a closure that can intialize the dictionary's
/// elements. The closure must return `c`, the number of initialized elements
/// in both buffers, such that the elements in the range `0..<c` are
/// initialized and the elements in the range `c..<capacity` are
/// uninitialized.
///
/// The resulting dictionary has a `count` less than or equal to `c`. The
/// actual count is less iff some of the initialized keys were duplicates.
/// (This cannot happen if `allowingDuplicates` is false.)
///
/// The buffers passed to the closure are only valid for the duration of the
/// call. After the closure returns, this initializer moves all initialized
/// elements into their correct buckets.
///
/// - Parameters:
/// - capacity: The capacity of the new dictionary.
/// - allowingDuplicates: If false, then the caller guarantees that all keys
/// are unique. This promise isn't verified -- if it turns out to be
/// false, then the resulting dictionary won't be valid.
/// - body: A closure that can initialize the dictionary's elements. This
/// closure must return the count of the initialized elements, starting at
/// the beginning of the buffer.
@inlinable
public // SPI(Foundation)
init(
_unsafeUninitializedCapacity capacity: Int,
allowingDuplicates: Bool,
initializingWith initializer: (
_ keys: UnsafeMutableBufferPointer<Key>,
_ values: UnsafeMutableBufferPointer<Value>,
_ initializedCount: inout Int
) -> Void
) {
self.init(_native: _NativeDictionary(
_unsafeUninitializedCapacity: capacity,
allowingDuplicates: allowingDuplicates,
initializingWith: initializer))
}
}

extension _NativeDictionary {
@inlinable
internal init(
_unsafeUninitializedCapacity capacity: Int,
allowingDuplicates: Bool,
initializingWith initializer: (
_ keys: UnsafeMutableBufferPointer<Key>,
_ values: UnsafeMutableBufferPointer<Value>,
_ initializedCount: inout Int
) -> Void
) {
self.init(capacity: capacity)
var initializedCount = 0
initializer(
UnsafeMutableBufferPointer(start: _keys, count: capacity),
UnsafeMutableBufferPointer(start: _values, count: capacity),
&initializedCount)
_precondition(initializedCount >= 0 && initializedCount <= capacity)
_storage._count = initializedCount

// Hash initialized elements and move each of them into their correct
// buckets.
//
// - We have some number of unprocessed elements at the start of the
// key/value buffers -- buckets up to and including `bucket`. Everything
// in this region is either unprocessed or in use. There are no
// uninitialized entries in it.
//
// - Everything after `bucket` is either uninitialized or in use. This
// region works exactly like regular dictionary storage.
//
// - "in use" is tracked by the bitmap in `hashTable`, the same way it would
// be for a working Dictionary.
//
// Each iteration of the loop below processes an unprocessed element, and/or
// reduces the size of the unprocessed region, while ensuring the above
// invariants.
var bucket = _HashTable.Bucket(offset: initializedCount - 1)
while bucket.offset >= 0 {
if hashTable._isOccupied(bucket) {
// We've moved an element here in a previous iteration.
bucket.offset -= 1
continue
}
// Find the target bucket for this entry and mark it as in use.
let target: Bucket
if _isDebugAssertConfiguration() || allowingDuplicates {
let (b, found) = find(_keys[bucket.offset])
if found {
_internalInvariant(b != bucket)
_precondition(allowingDuplicates, "Duplicate keys found")
// Discard duplicate entry.
uncheckedDestroy(at: bucket)
_storage._count -= 1
bucket.offset -= 1
continue
}
hashTable.insert(b)
target = b
} else {
let hashValue = self.hashValue(for: _keys[bucket.offset])
target = hashTable.insertNew(hashValue: hashValue)
}

if target > bucket {
// The target is outside the unprocessed region. We can simply move the
// entry, leaving behind an uninitialized bucket.
moveEntry(from: bucket, to: target)
// Restore invariants by lowering the region boundary.
bucket.offset -= 1
} else if target == bucket {
// Already in place.
bucket.offset -= 1
} else {
// The target bucket is also in the unprocessed region. Swap the current
// item into place, then try again with the swapped-in value, so that we
// don't lose it.
swapEntry(target, with: bucket)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a cool algorithm, @lorentey 👏

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to work it out myself because I couldn't picture how to hash-in-place like this, but I think I got it. Nice work. (Did you adapt it from somewhere or come up with it yourself?)

Does this obsolete DictionaryBuilder?

For my own understanding:

  • The storage is separated into three regions: "before bucket", "bucket up to count", and "count and after".
  • Everything in "before bucket" and "count and after" is either uninitialized or in use. Everything in "bucket up to count" is either unprocessed or in use.
  • "in use" is tracked by the bitmap in native.hashTable, the same way it would be for a working Dictionary. Whenever we see such a bucket in the middle section, we skip over it, and native.hashTable.insertNew will never return an in-use bucket. (This is the key thing I was missing when trying to picture the algorithm on my own.)
  • When processing an item, we're basically deciding how to move it from the middle section. Either we're moving it elsewhere in the middle section, in which case we do a swap so that we don't lose the unprocessed item that was there, or we're moving it outside the middle section, in which case it's okay to leave behind an uninitialized hole (because we're about to advance bucket).

Copy link
Member Author

@lorentey lorentey Dec 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a nice analysis & it's exactly right -- in fact, I'll steal it to document the invariants!

This is unlikely to be a new algorithm -- I can swear I saw something like this done before, although maybe not in these exact circumstances. (Knuth, maybe? This feels like the sort of thing he may include as an exercise.)

We're experimenting with switching some forms of bridging from _DictionaryBuilder to this, but it's only an experiment at this point. This is designed specifically to work with the narrow use case of -[NSDictionary objects:keys:count:].

It's quite more fiddly than _DictionaryBuilder, so if the input comes from a sequence of unique key/value pairs, then I expect the builder will likely still be faster. (Although benchmarks may surprise us!)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there's a tiny simplification in going backwards instead of forwards—indeed, @Catfish-Man described it to me that way at first. That way you only have two regions to think about (and for a concrete benefit, only have to make one comparison).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea; applied!

}
// When there are no more unprocessed entries, we're left with a valid
// Dictionary.
}
}
1 change: 0 additions & 1 deletion stdlib/public/core/HashTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ extension _HashTable {
@inlinable
@inline(__always)
internal init(offset: Int) {
_internalInvariant(offset >= 0)
self.offset = offset
}

Expand Down
13 changes: 12 additions & 1 deletion stdlib/public/core/NativeDictionary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ extension _NativeDictionary { // Low-level unchecked operations
@inline(__always)
internal func uncheckedDestroy(at bucket: Bucket) {
defer { _fixLifetime(self) }
_internalInvariant(hashTable.isOccupied(bucket))
_internalInvariant(hashTable.isValid(bucket))
(_keys + bucket.offset).deinitialize(count: 1)
(_values + bucket.offset).deinitialize(count: 1)
}
Expand Down Expand Up @@ -619,11 +619,22 @@ extension _NativeDictionary: _HashTableDelegate {
@inlinable
@inline(__always)
internal func moveEntry(from source: Bucket, to target: Bucket) {
_internalInvariant(hashTable.isValid(source))
_internalInvariant(hashTable.isValid(target))
(_keys + target.offset)
.moveInitialize(from: _keys + source.offset, count: 1)
(_values + target.offset)
.moveInitialize(from: _values + source.offset, count: 1)
}

@inlinable
@inline(__always)
internal func swapEntry(_ left: Bucket, with right: Bucket) {
_internalInvariant(hashTable.isValid(left))
_internalInvariant(hashTable.isValid(right))
swap(&_keys[left.offset], &_keys[right.offset])
swap(&_values[left.offset], &_values[right.offset])
}
}

extension _NativeDictionary { // Deletion
Expand Down
56 changes: 56 additions & 0 deletions validation-test/stdlib/Dictionary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5547,6 +5547,62 @@ DictionaryTestSuite.test("IndexValidation.RemoveAt.AfterGrow") {
d.remove(at: i)
}

DictionaryTestSuite.test("BulkLoadingInitializer.Unique") {
for c in [0, 1, 2, 3, 5, 10, 25, 150] {
let d1 = Dictionary<TestKeyTy, TestEquatableValueTy>(
_unsafeUninitializedCapacity: c,
allowingDuplicates: false
) { keys, values, count in
let k = keys.baseAddress!
let v = values.baseAddress!
for i in 0 ..< c {
(k + i).initialize(to: TestKeyTy(i))
(v + i).initialize(to: TestEquatableValueTy(i))
count += 1
}
}

let d2 = Dictionary(
uniqueKeysWithValues: (0..<c).map {
(TestKeyTy($0), TestEquatableValueTy($0))
})

for i in 0 ..< c {
expectEqual(TestEquatableValueTy(i), d1[TestKeyTy(i)])
}
expectEqual(d2, d1)
}
}

DictionaryTestSuite.test("BulkLoadingInitializer.Nonunique") {
for c in [0, 1, 2, 3, 5, 10, 25, 150] {
let d1 = Dictionary<TestKeyTy, TestEquatableValueTy>(
_unsafeUninitializedCapacity: c,
allowingDuplicates: true
) { keys, values, count in
let k = keys.baseAddress!
let v = values.baseAddress!
for i in 0 ..< c {
(k + i).initialize(to: TestKeyTy(i / 2))
(v + i).initialize(to: TestEquatableValueTy(i / 2))
count += 1
}
}

let d2 = Dictionary(
(0 ..< c).map {
(TestKeyTy($0 / 2), TestEquatableValueTy($0 / 2))
},
uniquingKeysWith: { a, b in a })

expectEqual(d1.count, d2.count)
for i in 0 ..< c / 2 {
expectEqual(TestEquatableValueTy(i), d1[TestKeyTy(i)])
}
expectEqual(d2, d1)
}
}

DictionaryTestSuite.setUp {
#if _runtime(_ObjC)
// Exercise ARC's autoreleased return value optimization in Foundation.
Expand Down