No safe efficient ways to do three-way string comparisons in Go
go101.org
go101.org
Others have already talked about the performance aspect but I'm just baffled at the comment basically saying nobody should use this function anyway.
I expect the need for 3-way compares isn't that uncommon, why tell people not to use it?
It's great to have this idea that the compiler should optimize all comparison situations but a) it doesn't yet and b) people still want a 3-way compare function anyway.
Basically it's saying "don't use a function at all", or even "write your own copies of this function". Even if the compiler one day becomes smart enough to optimize you still end up with tons of code duplication if people followed the advice.
And for people who care about performance it means they'll use a 3rd party library or implement their own "optimizations" that might not be effective or even buggy.
Literally nobody is benefiting from this.
What's the point of providing this function but actually thinking nobody should use it, in a comment no less instead of documentation?
Now, there may be other reasons, e.g. the author thinking 3-way compare is a bad pattern in the first place. If argued well, maybe I could agree. But that argument isn't made here.
I'm also not saying they needed to optimize this off the bat. A comment saying "we don't think there is a big demand yet, will optimize when we see the demand" would've been acceptable.
I guess this is for stylistic reasons, but I don't know why anyone would feel strongly about doing it one way or the other.
To be fair, I agree that seeing bad examples of its use shown by one of the other folks here, some could have been avoided by them being forced to inline, but funnily those examples still exist despite the desires of the function author.
Nothing was achieved here.
[0]: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=9m28s&themeRef...
If that’s the justification to keep an implementation bad on purpose (which I’m not sure is the actual intention, but is at least that’s what the referenced comment claims), then I’m not even sure I speak the same language as the people who made that decision.
I've worked in codebases with library helpers for everything that mostly served to turn two clear lines into a function call. Once such functions exist, folks feel obligated to use them; after all, why duplicate code? So now, what would've been a couple dozen lines of self-contained straightforward code has 3 imports and 5 functions you need to be familiar with to understand it. It also makes compilation more expensive.
I'm not saying library functions are bad or anything like that, but there's value to keeping the typical vocabulary of code small, of not adding new dependencies to solve trivial problems. Especially in a standard library that is widely used and you expect to be maintained for years. Providing functions that solve problems that are impossible or tricky in the language is important; providing `Plus(a, b int) int` that wraps `+` makes the library worse.
I think there's a good argument for providing Compare() and Abs() and other fairly trivial functions even if it is perhaps less effort to not use them in most cases, but for a stdlib I can appreciate the logic of leaving out what isn't providing clear value.
edit: clarity
I think that sums up the philosophy, though maybe not as well as “Nothing was achieved here”, which I would like to translate into Latin and get on some stickers.
Manually writing the three-way comparison fits in with these goals. If slices were comparable then I'd wager that bytes.Compare would not exist.
Real code tends to not be that trivial, and that's why real code most likely shouldn't be using strings.Compare.
https://pkg.go.dev/golang.org/x/text/collate#Collator.Compar...
If that were true, they wouldn't have made strings comparable in the first place, and they wouldn't recommend that callers inline the implementation (they'd recommend calling some locale-dependent function instead).
An explicit comparison like "naïve" == "naïve" or "naïve" < "naïve" isn't any more clear about how the comparison is performed than Compare("naïve", "naïve") would be.
Code duplication is the go way
https://github.com/golang/go/blob/d28bf6c9a2ea9b992796738d03...
So the goal was to intentionally nerf Compare() to discourage code the golang authors considered less clear. I'm not sure bad performance is really discouraging usage though, it just penalizes folks that use stdlib.
I wonder if they'd accept a PR to switch to runtime.cmpstring today?
If that's true, it further proves that I disagree with the philosophy of the Go designers on pretty much everything.
If your language provides multiple ways to do something, they should all be optimized in good faith. To do what you suggested is insane and user-hostile.
For optimal performance but suboptimal clarity, they could use a runtime implementation, but ideally the clear code would be fast, so best not to compromise the clarity for performance unless it proves important.
I've been following Go content for years, this is the first I can recall hearing of it, so I'm mostly fascinated that people care, given that this seems to be doing what the most classic optimization advice suggests and not messing it up prematurely.
1) It's not some random function, it's part of the standard library.
2) Three-way string compares are far from uncommon, and string comparisons are relatively expensive. Therefore, a speedup would be useful.
Optimizing this function would not count as "premature".
> t. The conventional wisdom shared by many of today's software engineers calls for ignoring efficiency in the small; but I believe this is simply an overreaction to the abuses they see being practiced by pennywise-and-pound-foolish programmers
> in established engineering disciplines a 12 % improvement, easily obtained, is never considered marginal
> when it's a question of preparing quality programs, I don't want to restrict myself to tools that deny me such efficiencies.
> We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.
Keep in mind that in this case the argument is being made against goto, which is effectively an inlined `jmp` instruction that can do all sorts of insane things just to save a few instructions. This quote is discouraging a case where the complexity is extreme and the benefit is minor.
All that people can seem to remember is "premature optimization is the root of all evil".
Yeah, no. The founding principle of Go is to avoid multiple ways. If multiple ways emerge anyway, move things&people around until one of them becomes strongly preferred.
There's nothing inherently bad about the approach, it's a choice which has costs and benefits.
c1, c2 := a[i], b[i]
I'm not entire sure if I see the problem or follow why just using runtime·cmpstring is such a bad thing; doesn't seem that "overengineered" to me.
Both strings.Compare and comparison operators have their respective best use scenarios.
> This function should be used ONLY if it makes code clearer.
> It is not here for performance. Remove any performance benefit.
Insane troll logic, pure and simple.
A reasonable compromise would be to just implement a single pass three-way compare in native Go instead of optimized assembler, and then if users keep requesting it be optimized, at that point make the compiler improvements or write the hand-tuned assembler version.
Otherwise what you're going to get is people using messy workarounds to do a 3-way compare, like doing a byte-wise compare that isn't unicode-correct. Blech.
So, making it a simple Go function was more work (at least, as an individual change) because they could've left it.
A three-way compare in native Go would likely be slower in most cases than the "slow" version that exists there, because in actually equal or size varying cases the "slow" one gets sent directly to optimized platform-tuned assembly, and the other cases still end up with tuned multibyte comparison stuff that likely wouldn't be possible in the stock Go compiler without clever bounds check removal. A compiler that can make that three-way quite fast is desirable, and there are reasons to do that independent of that function, but even Rust uses unsafe and farms out to builtin tuned memcmp stuff for 3 way compare of strings.
My theory is that strings.Compare _was_ known to be faster, and people starting preferring it because it was faster, and that in part prompted the change. Most engineers use a faster approach if available, even if it is a bit clunkier and not necessary (as this comment section shows, many folks are outraged at the idea of code not optimized for maximal performance). Encouraging bad use because a function is unintentionally faster than the naive thing is a bug in a stdlib.
if cmp := strings.Compare(x.first, y.first); cmp != 0 {
return cmp < 0
}
if cmp := strings.Compare(x.second, y.second); cmp != 0 {
return cmp < 0
}
...
Compared to the != then < approach, it makes only a single pass over the string data. To this day I never understood the justification for intentionally making it slower, or why the style of code above isn't reasonable.For a simple example, perhaps this is a list of strings that require Unicode normalization to be properly interpreted as human text that you are storing into a TreeMap for efficient retrieval. When you are adding "\u00f1" to the list, you wouldn't want the collection to say that it's already there because it already had "\u006e\u0303".
It's ridiculous to what lengths you have to go to understand which part of a string comes earlier or later.
Simple example: semantic versioning allows "1.2.3alpha" and also 1.2.3-beta", but which one comes first now...
- Is 1.2.3 > 1.2.3omega?
- Is 1.2.3 > 1.2.3beta?
- Is 1.2.3gamma > 1.2.3?
In the Linux world it gets even funnier cause they invented SONAME fields that reflect breaking API changes instead of forcing packages to comply with semantic versioning syntax. Oftentimes there is a package version of e.g. 0.4.7 that has an SONAME of 12.7 on the filesystem.
Add to that the ~prerelease suffix syntax in Debian based distros which are maintained downstream, and all the +buildid or .commithash or -revision123 suffixes and you've landed in string comparison hell.
When I started I would have never guessed that this is such a complex problem to solve in golang.
Perhaps I missed it, but I thought [Semantic Versioning](https://semver.org/) required a “-“ between the patch number and a prerelease identifier since at least version 1.0.0 (with 1.0.0-beta allowing a “.” instead of a “-“), no?
No, according to the spec, the hyphen is mandatory: "A pre-release version MAY be denoted by appending a hyphen and a series of dot separated identifiers immediately following the patch version."
MAY has not the same meaning as MUST. MAY is optional, MUST is mandatory.
1: https://semver.org/#backusnaur-form-grammar-for-valid-semver...
- Is 1.2.3 > 1.2.3omega? No
- Is 1.2.3 > 1.2.3beta? No
- Is 1.2.3gamma > 1.2.3? Yes
I don't see ambiguity in your examples.I wrote this example, because I knew the answer. And your interpretation (the same as my initial one) is wrong :)
> Pre-release versions have a lower precedence than the associated normal version.
> 2. A normal version number MUST take the form X.Y.Z where X, Y, and Z are non-negative integers, and MUST NOT contain leading zeroes. X is the major version, Y is the minor version, and Z is the patch version.
What about Go makes this different? That's how I'd solve this is any language
It would be harder still to know how those normal compare functions can be combined to implement an efficient 3-way compare. For strings and other built in types, this might be possible. But for user defined types that compare in weird ways, it could be very hard.
It's much better to let the programmer define the 3-way compare and use that to derive all the comparison operators.
public MyFile implements Comparable { ... long timestamp; ... @Override public int compare(Object other) { return (int)(((MyFile)other).timestamp - timestamp; } ... }
The int conversion loses the sign of the subtract. The particular use of the code was sorting files to delete the X number of oldest.
More examples about the dangers with just ints: https://stackoverflow.com/questions/2728793/java-integer-com...
The comparable API in Java came from the C qsort API. It would have been better to just have a less-than method. Kind of surprised to see this in Go near a core API.
The reason to avoid subtraction is that you can get integer underflow if one operand is negative and one is positive, and then the result of Long.signum(b - a) is different from Long.compare(a, b).
Using -1, 0 and 1 to represent these feels like something that was a clever trick for low level code on a PDP-11 and hasn't been a good idea since the 1970s.
For two values `x` and `y` of the same type with fields `a` and `b`, this lets you compare on `a` first and then on `b` by writing
``` x.a.cmp(&y.a).then(x.b.cmp(&y.b)) ```
(x.a, x.b) < (y.a, y.b)
btw you can format as code by indenting two spaces.⇒ I think the comment is outdated, and there is a safe efficient way to do three-way string comparisons in go.
Caveat: I’m not that good at reading modern assembly, and I do not understand why there also is a call to runtime.memequal in that code. ⇒ Corrections welcome.
If so, then I guess there's still a redundant call to runtime.memequal inserted by the compiler.
It's hard to imagine any of this matters at all, in practice, which is probably why the go authors haven't bothered addressing the issue.
I searched through my random checked out applications to see who calls strings.Compare, and why.
Most of them are mistaken sorting. In gVisor, there is this in a test:
...
cmpopts.SortSlices(func(a, b testEntryEventInfo) bool {
return strings.Compare(string(a.Addr), string(b.Addr)) < 0
}),
...
This pattern shows up a bunch: // Less implements sort.Interface.
func (s fooSlice) Less(i, j int) bool {
return strings.Compare(s[i].Whatever, s[j].Whatever) == -1
}
In Go's Snowflake library, there is this: if strings.Compare(s0, name) != 0 {
t.Error("a file was not downloaded by GET")
}
This is exceedingly interesting to me because you'd expect Compare to cost more than < (it does more stuff), and yet people are somehow baited into writing it. I'm guessing what happens is that people don't know that "<" works for strings, and they reach for strings.Compare, so the documentation is trying to talk them out of a program that doesn't actually make sense. In the case of "!= 0", they just wanted "s0 != name", which I take to mean they had absolutely no idea that strings in Go are comparable at all.All in all, I get the impression that people coming from other languages to Go have different expectations about what the language can do. The documentation for strings.Compare is a logical place to correct them, but it doesn't bother. Just a comment that says "sucks if you use this".
BTW, there were a couple of correct uses. Gazelle has a sort function like this in a few places:
sort.SliceStable(sortedFiles, func(i, j int) bool {
if cmp := strings.Compare(sortedFiles[i].Path, sortedFiles[j].Path); cmp != 0 {
return cmp < 0
}
return sortedFiles[i].DefName < sortedFiles[j].DefName
})
Here they are punished for the inefficiency of strings.Compare; they would actually take advantage of it in the common case where the paths aren't equal over the easier approach of: if xs[i].Path != x[j].Path {
return xs[i].Path < xs[j].Path
}
return xs[i].DefName < xs[j].DefName
Another correct use was in "goja" (a Javascript interpreter), which uses strings.Compare to implement Javascript's <string>.compareTo(<string>). Whether or not Javascript code is using compareTo correctly is an analysis I do not have the energy to do :)I would expect it to be the same within a couple nanoseconds, because "more stuff" is an extremely small amount of stuff.
The naive direct implementation of a 3 way compare does the exact same comparisons as a direct implementation of less than, it merely returns more information, which makes it more generally useful.
What documentation though? There's an internal comment but it's not documentation and it's not really arguing against the use of this type of comparison (just against use of the implemented function).
If the documentation for this function went on along the lines of "we discourage the use of 3 way compares as we consider it an antipattern, do X instead, there's literally no reason to ever use 3 way compares, we know it, here's why..." maybe, but it doesn't.
Now, maybe 3 way comparison is not common but it is a distinct operation with known optimization opportunities. I wouldn't readily claim that the operation is not needed by anyone.
Yeah, sorry about that. I read the actual documentation and nothing is mentioned. I realized later on in my comment though ;)
But it does. It literally says "It is usually clearer and always faster to use the built-in string comparison" and it always said that.
https://pkg.go.dev/strings@go1.5#Compare
https://pkg.go.dev/strings@go1.19.2#Compare
The entire documentation for this function is four short sentences. If my code editor didn't make it easy and natural to see them, I'd rethink my shit right now.
Three-way comparisons show up naturally when doing a binary search or implementing binary trees.
Interestingly, the binary search implementation in the Go stdlib doesn't need it, but that's because it's only doing part of what you'd normally expect a binary search function to do and shifts the responsibility for the actual equality check to the caller [1].
I see it in things like transportation, power generation, recycling. People refuse to use the best methods because then people will never switch - except they never offer the thing that you are supposed to switch to.
I'm actually kinda surprised to see it in a programing language.
the reason it's faster is because the function is intentionally slow, but
(I interpret that) rsc considers the native binary operators to be clearer than a function with the name `Compare`. Honestly, I'd expect people to use `==` as well over `strings.Compare`.
`==` _does_ work for strings, but I believe `[]byte` would try comparing memory addresses which is why the `bytes.Compare` version exists and is optimized (some assembly version, probably costly to maintain per arch?).
The author seems to assume nobody needs a three way compare, which I feel is assuming a bit too much.
In particular because it is there because someone is using/needing it already.
Basically their comment actually says that everyone who does need a three way compare should just re-implement what they wrote there (and maybe they'll make sure everyone's code is optimized later). Sounds pretty bizarre to me to discourage code reuse. And if three way compares are a pattern they want to discourage in the first place it should be elaborated on in documentation.
I interpret this as replacing a strange but more optimal call to the runtime with a straight forward but less fast stdlib implementation. rsc's comment seems more like a todo note than and ideological stance (to me at least). Something like: "I removed this weird fast way to do this that was breaking stuff b/c of linker things and replaced it with some straightforward code that's slower. If we want this to be more optimal in the future we can fix it at the compiler level.". But I'm just reading into it. Someone should ask him and tell him to explain himself :) It's been 8 years. What have you been doing?
Until there are the resources and demand to redesign a system for an important goal, the current system should be optimized where it reasonably can be, to better meet the same goal.
Until there are the resources and demand for a deeper redesign, the current code should be optimized as much as it reasonably can be.
Sometimes local optimizations are not worth the cost to make them, a local benefit/effort maximum has been reached. Only a more global change can take things further. But this doesn't look like one of those cases.
It is also worth noting that it is very rare that actual global redesigns are ever economical. Virtually every improvement, in anything, at any scale, is a "local redesign" in some sense.
So "up with" economical local optimizations!
Hiding language features in optimization passes is a dangerous game. It’s one of the reasons I don’t like implicit tail calls.
But yes, an annotation that forces the compiler to optimize them (or give you a warning or even error when unable to do so) can be useful.
Strings are so common, it’s insane they don’t optimize
Edit: Never mind, it doesn't: https://github.com/golang/go/blob/d28bf6c9a2ea9b992796738d03...
But checking lengths doesn't really help you: it only tells you when strings are not equal, and you would still have to walk the string to see which one is larger/smaller.
Only for a three-way comparison. If all you care about is equality different lengths gives a fast path for inequality.
It's a 100% increase. If you're doing a lot of string parsing, it will add up. Probably not very noticeable, and maybe you'll have extra cachemisses in a critical path.
If the function shouldn't be used, why create it at all, or why not show compiler errors / runtime error
The obvious way of comparing strings is to do a comparison of the content for the minimum of their lengths (which should be computed in a branchless way), and that should be done in an optimized assembly loop, for which suitable SIMD instructions are available in most modern CPUs. In many C standard libraries the function memcmp already provides such an optimized implementation, which should be used for the comparison of strings of equal length.
Then, only when the result of the comparison is equal, the 2 string lengths are compared, to provide the final comparison result.
Therefore, when a good memcmp is already available, a string comparison consists of a minimum computation, a memcmp invocation and an optional integer comparison of the lengths.
I'm assuming encoding and utf-8 normalization and other similar things are not in scope when answering this.
//go:linkname cmpstring runtime.cmpstring func cmpstring(a, b string)
There are many ways to approach this type of problem, and–while I understand that some may want to lean on this approach, based on their experiences with another language–this is an academic comparison, and far less useful than one might believe when building performant code.
To each their own.
Man, it is the key to do binary-searching.
> ... based on their experiences with another language ...
Sorry, this is language independent.
It's not that complicated and well documented, infact I'm sure someone has already done it in go.
If people really need the extra performance, they'll use unsafe to create byte slices backed by the strings, and then use bytes.Compare. Or they'll improve the compiler.
...and thus very likely end up with buggy code.
Though, if 3 way compare is the bottleneck, it seems like a real possibility that there are algorithmic optimizations that may be more effective than a faster 3 way compare, like using native comparison operators, hashing, using byte slices, etc. Or maybe not; I don't think I've seen a case where it's a bottleneck so I might be inaccurately imagining the cases where it'd happen.
I agree. Squeaky wheel gets the grease.
Here's clarity:
datatype order https://smlfamily.github.io/Basis/general.html#SIG:GENERAL.o...
function compare https://smlfamily.github.io/Basis/string.html#SIG:STRING.com...
case String.compare(a, b) of
| LESS => ...
| EQUAL => ...
| GREATER => ...Is Go forkable (for lack of a better word)? That's the only question that comes to mind when I read the article. You have the source code, and this shouldn't be a difficult change to make, so I think "fork-and-fix and see who follows along" should be the mentality to practice in this case.
If you want to implement strings.Compare better, you can write a package and publish it for other folks to import. The function is not particularly special.
"The only sure path to failure is to not even try."
Or are you both implying the power of and supporting the massive monopoly that Google already has?
This is not a reasonable assessment.
Golang is massive; why would anyone switch to not-Golang which isn't backed by a large organisation and just has one string comparison optimisation?
And why would anyone trust a forking entity that forked a runtime to make a change that could be packaged as a library, as the comment you're replying to already suggests?