-
Notifications
You must be signed in to change notification settings - Fork 10.5k
[String] Add UTF-8 fast-paths for Foundation initializers #21959
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
Conversation
Many Foundation initializers could benefit from faster string construction and subsequent reads in Swift 5. Add UTF-8 fast paths for when constructing a string from a valid UTF-8 code units.
@swift-ci please test |
@swift-ci please benchmark |
Build comment file:Performance: -O
Performance: -Osize
Performance: -Onone
How to read the dataThe tables contain differences in performance which are larger than 8% and differences in code size which are larger than 1%.If you see any unexpected regressions, you should consider fixing the Noise: Sometimes the performance results (not code size!) contain false Hardware Overview
|
Do we have test coverage for these paths? |
@swift-ci please test |
@swift-ci please benchmark |
Build failed |
Build failed |
Build comment file:Performance: -O
Performance: -Osize
Performance: -Onone
How to read the dataThe tables contain differences in performance which are larger than 8% and differences in code size which are larger than 1%.If you see any unexpected regressions, you should consider fixing the Noise: Sometimes the performance results (not code size!) contain false Hardware Overview
|
if let str = String(validatingUTF8: bytes) { | ||
self = str | ||
return | ||
} | ||
if let ns = NSString(utf8String: bytes) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not remove the NSString-and-bridge part of this entirely?
Then we could also make it inlineable
since it trivially forwards to the stdlib function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm trying to preserve behavior if the bytes are invalidly encoded. We should probably deprecate this initializer.
@swift-ci please test |
@itaiferber these are some pretty exciting numbers, and they actually understate the benefit as they only measure creation time: reads of these fast-pathed strings will also be on all our fast-paths. I'm working on the last regression currently, but we'd like to get this ready ASAP |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall, looks good! Perf numbers are great 😄 I am somewhat concerned about memory binding here though — can we express this without the binding?
@@ -381,6 +395,14 @@ extension String { | |||
/// Returns a `String` initialized by converting given `data` into | |||
/// Unicode characters using a given `encoding`. | |||
public init?(data: __shared Data, encoding: Encoding) { | |||
if encoding == .utf8, | |||
let str = data.withUnsafeBytes({ | |||
String._tryFromUTF8($0.bindMemory(to: UInt8.self)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mm, in previous discussions, we've agreed that the safety of binding Data
is dubious given the fact that the underlying buffer may have been previously bound. Are we making the assertion that binding to a trivial type here is safe? /CC @atrick
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to express the UTF-8 validation in terms of raw buffer pointers instead of typed? (Or overload to make this possible safely?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if this is something to be concerned with, but if it is:
The fix would be for Data to provide a way to access its contents as a UBP<UInt8>
, since Data is the owner (or knows who the owner is) and is the only one allowed to do so. If String exposed something taking a URBP
, then treated it as a collection of UInt8
s, that seems like it's sweeping the potential time bomb under the living room rug.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Data
might know who the owner is, but it doesn't necessarily make it safe to bind the contents to UInt8
, since the previous owner may have bound the buffer to a nontrivial type, and Data
cannot unbind it — URBP
s specifically do allow reading as UInt8
s via a raw interface, though, making it always safe to read from.
I'm not terribly concerned (as we've previously discussed — we can't prevent someone from doing something like reading arbitrary data bound to some non-trivial type U
and trying to pass it along to be interpreted as UTF-8), but we did do a lot in Data
to make sure we don't invoke undefined behavior like this silently on others' behalf. @atrick might have stronger opinions here, but I agree with him in that we shouldn't be repeating that here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI withContiguousStorageIfAvailable
provides a typed pointer, so Data could use that. Unfortunately, URBP
doesn't support it (I guess because it doesn't want to bind random memory). EDIT: Oops, I thought Data supported that, but it's doing what URBP does.
I encountered that issue with #22028
Currently, if you write:
class MyClass {}
var s = [MyClass(), MyClass(), MyClass()]
let sbytes = withUnsafeBytes(of: &s) { String(decoding: $0, as: UTF8.self) }
you will invoke a fast-path which calls assumingMemoryBound(to: UInt8.self)
on the raw pointer, which IIUC is technically undefined behaviour. But it was marked "unsafe"...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any String utilities that we want to accept a raw byte stream, like Data, including validateUTF8 and _uncheckedFromUTF8 should either take URBP or some ContiguouslyStored protocol. Either will be source compatible with the existing APIs, so they can probably be fixed later.
Build failed |
@swift-ci please benchmark |
let address = Int(bitPattern: ptr) | ||
|
||
while (address &+ i) % stride != 0 && i < count { | ||
guard ptr[i] <= 0x7F else { return false } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't really semantically important, but just for readability could we stick to (& 0x80…) rather than using that in one place and <= 0x7F in the other?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Depending on what the expected trip-count is here, it may not be worth explicitly aligning. Did you experiment with this at all?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, and there's not really any time for much experimentation if this is going to ship. According to @jckarter, our loads and stores assumed aligned pointers, so I thought this was necessary for correctness.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good enough for now, then.
let bytes: UInt = UnsafePointer( | ||
bitPattern: address &+ i | ||
)._unsafelyUnwrappedUnchecked.pointee | ||
guard bytes & UInt(truncatingIfNeeded: 0x8080_8080_8080_8080 as UInt64) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should give the compiler folks a bug to unroll this so we don't have to do it by hand ;)
(Also, does truncatingIfNeeded elide truncation checks when given a constant like this? We definitely don't want any unnecessary branches in this loop)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean do the whole operation, or just the promoting to a higher stride? CC @eeckstein @aschwaighofer
(As wacky as it sounds, truncatingIfNeeded
is the way to skip checks)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both would be nice! But I meant doing multiple words per loop, which this doesn't currently do. We squeezed another 1.5x out of CF's by doing that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW, the first loop is unrolled AFAICT
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice. That matches my mental model of the state of LLVM's loop unroller, which is that it handles known trip counts well, but mostly bails on unknown trip counts.
@swift-ci please test |
Build failed |
Build failed |
245cacb
to
dc1d2d6
Compare
@swift-ci please test |
@swift-ci please benchmark |
Build failed |
Build failed |
Build failed |
Build comment file:Performance: -O
Performance: -Osize
Performance: -Onone
How to read the dataThe tables contain differences in performance which are larger than 8% and differences in code size which are larger than 1%.If you see any unexpected regressions, you should consider fixing the Noise: Sometimes the performance results (not code size!) contain false Hardware Overview
|
There we go, that gives us a good 2x for ASCII content. The regressions seem unrelated and flaky benchmarks. |
Perform ASCII checking using pointer-width strides, making sure to align properly.
dc1d2d6
to
3df9291
Compare
That force-push is just to squash the WIP commit in the middle. @swift-ci please smoke test |
Many Foundation initializers could benefit from faster string
construction and subsequent reads in Swift 5. Add UTF-8 fast paths for
when constructing a string from a valid UTF-8 code units.