Repository: nodejs/node
Pull request: src: improve StringBytes::Encode perf on UTF8

Tracking: #61041 

Most data is valid utf-8, no need to wait for v8 optimizations or for simdutf implementing fast replacement.
We can just check + simdutf in fast case.

This is a 2x-10x speedup according to https://github.com/lemire/jstextdecoderbench bench (+ I added extra cases)

There is still room for improvement here (e.g. avoiding triple scans), but this change alone improves results _significantly_ 
We can improve further iteratively
This performs mallocs only for valid strings, instead of optimistically malloc-ing and decoding until error
Switching that behavior to optimistic would be a separate PR (perf needs to be checked against _this_, not main or #61119)

### Buffer#toString() - utf8

pre-#61119:
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 18.21 GiB/s | 0.005 ms |
| Arabic lipsum | 79.771 KiB | 0.29 GiB/s | 0.266 ms |
| Chinese lipsum | 68.203 KiB | 0.34 GiB/s | 0.192 ms |
| Arabic + 2 * ASCII | 249.575 KiB | 0.73 GiB/s | 0.329 ms |

main with #61119 (landed):
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 36.75 GiB/s | 0.002 ms |
| Arabic lipsum | 79.771 KiB | 0.28 GiB/s | 0.273 ms |
| Chinese lipsum | 68.203 KiB | 0.33 GiB/s | 0.197 ms |
| Arabic + 2 * ASCII | 249.575 KiB | 0.69 GiB/s | 0.344 ms |

PR:
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 36.84 GiB/s | 0.002 ms |
| Arabic lipsum | 79.771 KiB | 2.03 GiB/s | 0.038 ms |
| Chinese lipsum | 68.203 KiB | 4.06 GiB/s | 0.016 ms |
| Arabic + 2 * ASCII | 249.577 KiB | 3.42 GiB/s | 0.072 ms |

### TextDecoder, loose

pre-#61119:
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 17.99 GiB/s | 0.005 ms |
| Arabic lipsum | 79.771 KiB | 0.28 GiB/s | 0.270 ms |
| Chinese lipsum | 68.203 KiB | 0.34 GiB/s | 0.194 ms |
| Arabic + 2 * ASCII | 249.577 KiB | 0.71 GiB/s | 0.333 ms |

main with #61119 (landed):
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 36.59 GiB/s | 0.002 ms |
| Arabic lipsum | 79.771 KiB | 0.28 GiB/s | 0.271 ms |
| Chinese lipsum | 68.203 KiB | 0.34 GiB/s | 0.192 ms |
| Arabic + 2 * ASCII | 249.577 KiB | 0.70 GiB/s | 0.340 ms |

PR:
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 36.78 GiB/s | 0.002 ms |
| Arabic lipsum | 79.771 KiB | 2.03 GiB/s | 0.038 ms |
| Chinese lipsum | 68.203 KiB | 4.01 GiB/s | 0.016 ms |
| Arabic + 2 * ASCII | 249.577 KiB | 3.42 GiB/s | 0.072 ms |

### TextDecoder, fatal

pre-#61119:
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 15.31 GiB/s | 0.006 ms |
| Arabic lipsum | 79.771 KiB | 0.27 GiB/s | 0.279 ms |
| Chinese lipsum | 68.203 KiB | 0.34 GiB/s | 0.194 ms |
| Arabic + 2 * ASCII | 249.577 KiB | 0.71 GiB/s | 0.338 ms |

main with #61119 (landed):
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 36.63 GiB/s | 0.002 ms |
| Arabic lipsum | 79.771 KiB | 0.28 GiB/s | 0.272 ms |
| Chinese lipsum | 68.203 KiB | 0.33 GiB/s | 0.197 ms |
| Arabic + 2 * ASCII | 249.577 KiB | 0.68 GiB/s | 0.351 ms |

PR:
| Test | Size | Throughput | Mean Time |
|------|------|------------|-----------|
| Latin lipsum (ASCII) | 84.902 KiB | 36.71 GiB/s | 0.002 ms |
| Arabic lipsum | 79.771 KiB | 1.70 GiB/s | 0.046 ms |
| Chinese lipsum | 68.203 KiB | 2.97 GiB/s | 0.022 ms |
| Arabic + 2 * ASCII | 249.577 KiB | 3.01 GiB/s | 0.082 ms |

---

cc @nodejs/performance 

Changed files:
filename: src/encoding_binding.cc
patch: @@ -379,6 +379,8 @@ void BindingData::DecodeUTF8(const FunctionCallbackInfo<Value>& args) {
       return node::THROW_ERR_ENCODING_INVALID_ENCODED_DATA(
           env->isolate(), "The encoded data was not valid for encoding utf-8");
     }
+
+    // TODO(chalker): save on utf8 validity recheck in StringBytes::Encode()
   }
 
   if (length == 0) return args.GetReturnValue().SetEmptyString();

filename: src/string_bytes.cc
patch: @@ -539,6 +539,25 @@ MaybeLocal<Value> StringBytes::Encode(Isolate* isolate,
         return ExternOneByteString::NewFromCopy(isolate, buf, buflen);
       }
 
+      if (buflen >= 32 && simdutf::validate_utf8(buf, buflen)) {
+        // We know that we are non-ASCII (and are unlikely Latin1), use 2-byte
+        // In the most likely case of valid UTF-8, we can use this fast impl
+        // For very short input, it is slower, so we limit min size
+        size_t u16size = simdutf::utf16_length_from_utf8(buf, buflen);
+        if (u16size > static_cast<size_t>(v8::String::kMaxLength)) {
+          isolate->ThrowException(ERR_STRING_TOO_LONG(isolate));
+          return MaybeLocal<Value>();
+        }
+        uint16_t* dst = node::UncheckedMalloc<uint16_t>(u16size);
+        if (u16size != 0 && dst == nullptr) {
+          THROW_ERR_MEMORY_ALLOCATION_FAILED(isolate);
+          return MaybeLocal<Value>();
+        }
+        size_t utf16len = simdutf::convert_valid_utf8_to_utf16(
+            buf, buflen, reinterpret_cast<char16_t*>(dst));
+        return ExternTwoByteString::New(isolate, dst, utf16len);
+      }
+
       val =
           String::NewFromUtf8(isolate, buf, v8::NewStringType::kNormal, buflen);
       Local<String> str;