grantila / fast-string-compare

A (much) faster String.prototype.localeCompare
MIT License
2 stars 0 forks source link

Isn't this just a less performant version of `a > b ? 1 : b > a ? -1 : 0`? (Also: `localeCompare` is now faster too) #1

Open lionel-rowe opened 1 week ago

lionel-rowe commented 1 week ago

This now appears to run slightly slower than localeCompare in V8, presumably due to localeCompare being better optimized than it was before. More to the point, it's hard to see how it could ever be faster than the "naive" a > b ? 1 : b > a ? -1 : 0, which I think should yield identical results for all cases, plus it stands to reason it would be the most optimized of all.

Some quick benchmarks seem to bear this out (deno 2.0.0, v8 12.9.202.13-rusty):

import { compare as fastStringCompare } from 'npm:fast-string-compare'
import { assertEquals } from 'jsr:@std/assert/equals'

function localeCompare(a: string, b: string) {
    return a.localeCompare(b, 'en-US')
}

function naive(a: string, b: string) {
    return a > b ? 1 : b > a ? -1 : 0
}

const fns = {
    fastStringCompare,
    localeCompare,
    naive,
}

for (const [name, fn] of Object.entries(fns)) {
    Deno.bench(name, () => {
        const a = 'a'
        const b = 'b'
        const c = 'c'

        for (const prefix of ['', a, b, c, 'aaaaaaaaaaaaaa', 'xxxxxxxxxxx', 'abcdefgh', 'gfdjkhgf', '返回德国空军法国很快就', '🍸🍻🍸🍹🌭🥪🎂🎈🌞']) {
            assertEquals(fn(prefix + a, prefix + b), -1)
            assertEquals(fn(prefix + a, prefix + c), -1)
            assertEquals(fn(prefix + b, prefix + a), 1)
            assertEquals(fn(prefix + a, prefix + a), 0)

            assertEquals(fn(prefix + a, prefix), 1)
            assertEquals(fn(prefix, prefix + a), -1)

            assertEquals(fn(prefix + a + prefix, prefix + b + prefix), -1)
            assertEquals(fn(prefix + a + prefix, prefix + c + prefix), -1)
            assertEquals(fn(prefix + b + prefix, prefix + a + prefix), 1)
            assertEquals(fn(prefix + a + prefix, prefix + a + prefix), 0)
        }
    })
}
benchmark           time/iter (avg)        iter/s      (min … max)           p75      p99     p995
------------------- ----------------------------- --------------------- --------------------------
fastStringCompare           36.5 µs        27,410 ( 24.7 µs …   1.5 ms)  32.4 µs 194.6 µs 262.5 µs
localeCompare               20.0 µs        49,880 ( 10.6 µs …   1.0 ms)  18.4 µs 173.7 µs 235.0 µs
naive                       14.1 µs        70,760 (  9.2 µs …   1.5 ms)  11.7 µs  75.8 µs 189.2 µs
lionel-rowe commented 1 week ago

Hmm... when adding my naive function and playing around the various included benchmarks/index.ts in Node, Deno, and Bun, the results seem to vary a lot depending on the environment, how the functions are being run, array size, etc. The outlier is localeCompare in Bun, which has atrocious performance for some reason (the other results in Bun are good, even for Intl.Collator, which is surprising as I'd expect it to be near-identical to localeCompare). Generally speaking fast tends to be slightly more performant than naive and similar or slightly slower than localeCompare.

I also added a naiveEqFirst version, which I expected to either be either near-identical to naive or to outperform it slightly (due to never needing to check b > a). The expected result seems to hold true, but even that isn't completely stable across runs (occasionally it's slower than naive).

function naiveEqFirst(a: string, b: string) {
    return a === b ? 0 : a > b ? 1 : -1
}

It still seems intuitive to me that naive and/or naiveEqFirst would be fastest as they just use basic operators, and it also seems intuitive that it would have the highest theoretical ceiling for perf given that the comparison is implemented in the engine itself (while also being less complex vs localeCompare), but I guess that isn't necessarily the case.