feat: Implement `FastString` - a potential zero-copy `jsi::String`
FastString is a highly optimized C++ string wrapper that either holds a std::string_view to raw, zero-copied data (FAST) - or a std::string (slower fallback path).
This is possible because react-native 0.78 introduced a new JSI API - jsi::String::getStringData(...) - which allows us to unsafely get the String's raw data without a copy - hence char*/std::string_view.
Because the runtime might choose to implement things differently, we don't know whether we're going to get one part or multiple parts - and it can be UTF8 or UTF16. So we need fallbacks (slow paths) for those cases. That's where std::string comes into play.
Benchmarks
Performance varies - some strings can be zero-copy optimized (UTF8), some can't (UTF16).
So if you use emojis or special characters, your string is gonna be a bit slower.
This Benchmarks 100k calls to set, 100k calls to getAllKeys(), and 100k calls to getString(). So 300.000 JSI method calls in total, out of which all of them make use of FastString.
const storage = new MMKV();
storage.clearAll();
let string = /* STRING GOES HERE */
const start = performance.now();
for (let i = 0; i < 100_000; i++) {
storage.set('dummy1', string);
storage.getAllKeys().map((k) => storage.getString(k));
}
const end = performance.now();
console.log(`Benchmark took ${(end - start).toFixed(0)}ms`);
UTF8
5 Characters
- Before: 114ms
- After: 109ms (4% faster)
2.500 Characters
- Before: 157ms
- After: 150ms (4% faster)
55.000 Characters
- Before: 1.888ms
- After: 1.624ms (14% faster)
990.000 Characters
- Before: 27.132ms
- After: 26.089ms (4% faster)
UTF16
5 Characters
- Before: 128ms
- After: 126ms (1% faster)
5.000 Characters
- Before: 7.853ms
- After: 4.187ms (46% faster)
Mixed strings with a real world app
- Before: 1.997ms
- After: 1.707ms (16% faster)
Only jsi::String::utf8(..) vs FastString
jsi::String::utf8(...): 547msFastString: 86ms (84% faster)
Isolated benchmark for UTF8 fast path - that's where it really shines.
Findings
- Apparently the
jsi::Stringconversion is only a small piece of the puzzle - it just makes up for ~4% of the total execution time in best cases. - Surprisingly, my UTF16 -> UTF8 conversion is ~40-50% faster than the one by JSI/Hermes. I quickly hacked it together with ChatGPT, but maybe the part that uses NEON/SIMD is faster? Looks like output data is correct too.
- I honestly expected more improvements in the UTF8 fast branch for huge strings (like JSON data). But apparently the main overhead is the
jsi::Functioncall, not the String conversion.
Hey, I can't help but notice that there are 100k calls to getAllKeys() instead of one call. Or did I miss something in JS that handles map() differently?
Hey nice to see you around here!
Yea there are 100k instead of 1 call, it was very late when I built this and I messed it up, lol. A friend already told me