Repository: nodejs/node
Pull request: fs: add c++ fast path for writeFileSync string utf8

## Summary

Added fast path in almost entirely C++ for `writeFileSync` with UTF8 encoding and string data. Also improves `appendFileSync` as it just uses `writeFileSync` under the hood. Only string data as Buffer seems questionable in benchmarks for this so I'll just leave it for strings for now.

**TL;DR: This makes `writeFileSync(path, data: string)` UTF8 up to ~2.5x faster depending on data size, especially when using file descriptors** 

## Bench results

### Benchmark in this PR
Note: running locally on Linux/i9/SSD

```
                                                                                                                 confidence improvement accuracy (*)   (**)  (***)
fs/bench-writeFileSync.js n=1000 func='writeFile' useBuffer='false' length=1024 useFd='false' encoding='utf8'                   -0.37 %       ±4.40% ±5.90% ±7.77%
fs/bench-writeFileSync.js n=1000 func='writeFile' useBuffer='false' length=1024 useFd='true' encoding='utf8'            ***     49.15 %       ±5.11% ±6.82% ±8.91%
fs/bench-writeFileSync.js n=1000 func='writeFile' useBuffer='false' length=102400 useFd='false' encoding='utf8'         ***      6.69 %       ±1.67% ±2.22% ±2.89%
fs/bench-writeFileSync.js n=1000 func='writeFile' useBuffer='false' length=102400 useFd='true' encoding='utf8'          ***     74.52 %       ±4.35% ±5.85% ±7.74%
fs/bench-writeFileSync.js n=1000 func='writeFile' useBuffer='false' length=1048576 useFd='false' encoding='utf8'        ***     16.05 %       ±1.60% ±2.14% ±2.81%
fs/bench-writeFileSync.js n=1000 func='writeFile' useBuffer='false' length=1048576 useFd='true' encoding='utf8'         ***     85.82 %       ±3.58% ±4.77% ±6.23%
```

Benchmark CI (old): https://ci.nodejs.org/view/Node.js%20benchmark/job/benchmark-node-micro-benchmarks/1421/

### Alternative benchmark

Alternative benchmark using [Bun's bench for `fs.copyFileSync`](https://github.com/oven-sh/bun/blob/main/bench/snippets/write-file.mjs) (string data, using paths)

#### Current (main)
```
benchmark           time (avg)             (min … max)       p75       p99      p995
------------------------------------------------------ -----------------------------
12 ascii          2.19 µs/iter     (2.07 µs … 2.67 µs)   2.21 µs   2.67 µs   2.67 µs
12 utf8           2.23 µs/iter     (2.08 µs … 2.32 µs)   2.26 µs   2.32 µs   2.32 µs
12288 ascii       6.14 µs/iter     (5.97 µs … 6.62 µs)   6.14 µs   6.62 µs   6.62 µs
18432 utf8       40.02 µs/iter  (36.13 µs … 204.16 µs)  40.05 µs   63.6 µs  69.01 µs
```

#### This PR
Long ascii: `6.14µs` -> `3.46µs` (~1.8x speedup)
Long utf8: `40.02µs` -> `18.25µs` (~2.2x speedup)

```
benchmark           time (avg)             (min … max)       p75       p99      p995
------------------------------------------------------ -----------------------------
12 ascii          1.97 µs/iter      (1.9 µs … 2.03 µs)   1.98 µs   2.03 µs   2.03 µs
12 utf8           1.95 µs/iter        (1.91 µs … 2 µs)   1.97 µs      2 µs      2 µs
12288 ascii       3.46 µs/iter      (3.39 µs … 3.6 µs)   3.47 µs    3.6 µs    3.6 µs
18432 utf8       18.25 µs/iter  (16.06 µs … 147.95 µs)  18.48 µs  21.77 µs  24.03 µs
```

Changed files:
filename: benchmark/fs/bench-writeFileSync.js
patch: @@ -0,0 +1,43 @@
+'use strict';
+
+const common = require('../common.js');
+const fs = require('fs');
+const tmpdir = require('../../test/common/tmpdir');
+tmpdir.refresh();
+
+// Some variants are commented out as they do not show a change and just slow
+const bench = common.createBenchmark(main, {
+  encoding: ['utf8'],
+  useFd: ['true', 'false'],
+  length: [1024, 102400, 1024 * 1024],
+
+  // useBuffer: ['true', 'false'],
+  useBuffer: ['false'],
+
+  // func: ['appendFile', 'writeFile'],
+  func: ['writeFile'],
+
+  n: [1e3],
+});
+
+function main({ n, func, encoding, length, useFd, useBuffer }) {
+  tmpdir.refresh();
+  const enc = encoding === 'undefined' ? undefined : encoding;
+  const path = tmpdir.resolve(`.writefilesync-file-${Date.now()}`);
+
+  useFd = useFd === 'true';
+  const file = useFd ? fs.openSync(path, 'w') : path;
+
+  let data = 'a'.repeat(length);
+  if (useBuffer === 'true') data = Buffer.from(data, encoding);
+
+  const fn = fs[func + 'Sync'];
+
+  bench.start();
+  for (let i = 0; i < n; ++i) {
+    fn(file, data, enc);
+  }
+  bench.end(n);
+
+  if (useFd) fs.closeSync(file);
+}

filename: lib/fs.js
patch: @@ -2343,6 +2343,19 @@ function writeFileSync(path, data, options) {
 
   validateBoolean(flush, 'options.flush');
 
+  // C++ fast path for string data and UTF8 encoding
+  if (typeof data === 'string' && (options.encoding === 'utf8' || options.encoding === 'utf-8')) {
+    if (!isInt32(path)) {
+      path = pathModule.toNamespacedPath(getValidatedPath(path));
+    }
+
+    return binding.writeFileUtf8(
+      path, data,
+      stringToFlags(options.flag),
+      parseFileMode(options.mode, 'mode', 0o666),
+    );
+  }
+
   if (!isArrayBufferView(data)) {
     validateStringAfterArrayBufferView(data, 'data');
     data = Buffer.from(data, options.encoding || 'utf8');

filename: src/node_file.cc
patch: @@ -2230,6 +2230,84 @@ static void WriteString(const FunctionCallbackInfo<Value>& args) {
   }
 }
 
+static void WriteFileUtf8(const FunctionCallbackInfo<Value>& args) {
+  // Fast C++ path for fs.writeFileSync(path, data) with utf8 encoding
+  // (file, data, options.flag, options.mode)
+
+  Environment* env = Environment::GetCurrent(args);
+  auto isolate = env->isolate();
+
+  CHECK_EQ(args.Length(), 4);
+
+  BufferValue value(isolate, args[1]);
+  CHECK_NOT_NULL(*value);
+
+  CHECK(args[2]->IsInt32());
+  const int flags = args[2].As<Int32>()->Value();
+
+  CHECK(args[3]->IsInt32());
+  const int mode = args[3].As<Int32>()->Value();
+
+  uv_file file;
+
+  bool is_fd = args[0]->IsInt32();
+
+  // Check for file descriptor
+  if (is_fd) {
+    file = args[0].As<Int32>()->Value();
+  } else {
+    BufferValue path(isolate, args[0]);
+    CHECK_NOT_NULL(*path);
+    if (CheckOpenPermissions(env, path, flags).IsNothing()) return;
+
+    FSReqWrapSync req_open("open", *path);
+
+    FS_SYNC_TRACE_BEGIN(open);
+    file =
+        SyncCallAndThrowOnError(env, &req_open, uv_fs_open, *path, flags, mode);
+    FS_SYNC_TRACE_END(open);
+
+    if (is_uv_error(file)) {
+      return;
+    }
+  }
+
+  int bytesWritten = 0;
+  uint32_t offset = 0;
+
+  const size_t length = value.length();
+  uv_buf_t uvbuf = uv_buf_init(value.out(), length);
+
+  FS_SYNC_TRACE_BEGIN(write);
+  while (offset < length) {
+    FSReqWrapSync req_write("write");
+    bytesWritten = SyncCallAndThrowOnError(
+        env, &req_write, uv_fs_write, file, &uvbuf, 1, -1);
+
+    // Write errored out
+    if (bytesWritten < 0) {
+      break;
+    }
+
+    offset += bytesWritten;
+    DCHECK_LE(offset, length);
+    uvbuf.base += bytesWritten;
+    uvbuf.len -= bytesWritten;
+  }
+  FS_SYNC_TRACE_END(write);
+
+  if (!is_fd) {
+    FSReqWrapSync req_close("close");
+
+    FS_SYNC_TRACE_BEGIN(close);
+    int result = SyncCallAndThrowOnError(env, &req_close, uv_fs_close, file);
+    FS_SYNC_TRACE_END(close);
+
+    if (is_uv_error(result)) {
+      return;
+    }
+  }
+}
 
 /*
  * Wrapper for read(2).
@@ -3071,6 +3149,7 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data,
   SetMethod(isolate, target, "writeBuffer", WriteBuffer);
   SetMethod(isolate, target, "writeBuffers", WriteBuffers);
   SetMethod(isolate, target, "writeString", WriteString);
+  SetMethod(isolate, target, "writeFileUtf8", WriteFileUtf8);
   SetMethod(isolate, target, "realpath", RealPath);
   SetMethod(isolate, target, "copyFile", CopyFile);
 
@@ -3190,6 +3269,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
   registry->Register(WriteBuffer);
   registry->Register(WriteBuffers);
   registry->Register(WriteString);
+  registry->Register(WriteFileUtf8);
   registry->Register(RealPath);
   registry->Register(CopyFile);
 

filename: test/parallel/test-fs-sync-fd-leak.js
patch: @@ -41,17 +41,34 @@ fs.writeSync = function() {
   throw new Error('BAM');
 };
 
+// Internal fast paths are pure C++, can't error inside write
+internalBinding('fs').writeFileUtf8 = function() {
+  // Fake close
+  close_called++;
+  throw new Error('BAM');
+};
+
 internalBinding('fs').fstat = function() {
   throw new Error('EBADF: bad file descriptor, fstat');
 };
 
 let close_called = 0;
 ensureThrows(function() {
+  // Fast path: writeFileSync utf8
   fs.writeFileSync('dummy', 'xxx');
 }, 'BAM');
 ensureThrows(function() {
+  // Non-fast path
+  fs.writeFileSync('dummy', 'xxx', { encoding: 'base64' });
+}, 'BAM');
+ensureThrows(function() {
+  // Fast path: writeFileSync utf8
   fs.appendFileSync('dummy', 'xxx');
 }, 'BAM');
+ensureThrows(function() {
+  // Non-fast path
+  fs.appendFileSync('dummy', 'xxx', { encoding: 'base64' });
+}, 'BAM');
 
 function ensureThrows(cb, message) {
   let got_exception = false;

filename: typings/internalBinding/fs.d.ts
patch: @@ -230,6 +230,9 @@ declare namespace InternalFSBinding {
   function writeString(fd: number, value: string, pos: unknown, encoding: unknown, usePromises: typeof kUsePromises): Promise<number>;
 
   function getFormatOfExtensionlessFile(url: string): ConstantsBinding['fs'];
+
+  function writeFileUtf8(path: string, data: string, flag: number, mode: number): void;
+  function writeFileUtf8(fd: number, data: string, flag: number, mode: number): void;
 }
 
 export interface FsBinding {