react-native-mmkv icon indicating copy to clipboard operation
react-native-mmkv copied to clipboard

feat: Implement `FastString` - a potential zero-copy `jsi::String`

Open mrousavy opened this issue 10 months ago • 2 comments

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(...): 547ms
  • FastString: 86ms (84% faster)

Isolated benchmark for UTF8 fast path - that's where it really shines.

Findings

  1. Apparently the jsi::String conversion is only a small piece of the puzzle - it just makes up for ~4% of the total execution time in best cases.
  2. 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.
  3. I honestly expected more improvements in the UTF8 fast branch for huge strings (like JSON data). But apparently the main overhead is the jsi::Function call, not the String conversion.

mrousavy avatar Feb 16 '25 21:02 mrousavy

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?

lingol avatar Feb 20 '25 09:02 lingol

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

mrousavy avatar Feb 20 '25 09:02 mrousavy