You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by ti...@apache.org on 2026/07/31 01:09:46 UTC

(opendal) branch main updated: perf(bindings/dotnet): copy read payloads once (#7993)

This is an automated email from the ASF dual-hosted git repository.

tisonkun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new 086e9853e perf(bindings/dotnet): copy read payloads once (#7993)
086e9853e is described below

commit 086e9853efb00ad92d27200874f1e9b829d56125
Author: Fatorin <sk...@gmail.com>
AuthorDate: Fri Jul 31 09:09:38 2026 +0800

    perf(bindings/dotnet): copy read payloads once (#7993)
---
 bindings/dotnet/Cargo.toml                         |   5 +
 .../OpenDAL.Tests/Behavior/ReadBehaviorTest.cs     |  40 +++++
 .../NativeObject/OpenDALBuffer.cs}                 |  44 ++++--
 .../OpenDAL/Interop/Result/OpenDALReadResult.cs    |   3 +-
 bindings/dotnet/OpenDAL/NativeMethods.cs           |  15 +-
 bindings/dotnet/OpenDAL/Operator.cs                |  29 ++--
 bindings/dotnet/src/buffer.rs                      | 171 +++++++++++++++++++++
 bindings/dotnet/src/byte_buffer.rs                 |  95 ------------
 bindings/dotnet/src/lib.rs                         |   2 +-
 bindings/dotnet/src/operator.rs                    |  37 +++--
 bindings/dotnet/src/result.rs                      |  22 +--
 11 files changed, 302 insertions(+), 161 deletions(-)

diff --git a/bindings/dotnet/Cargo.toml b/bindings/dotnet/Cargo.toml
index 717a17448..89adee7ab 100644
--- a/bindings/dotnet/Cargo.toml
+++ b/bindings/dotnet/Cargo.toml
@@ -106,6 +106,11 @@ opendal = { version = ">=0", path = "../../core", features = [
 ] }
 tokio = { version = "1.49.0", features = ["full"] }
 
+[dev-dependencies]
+# Only needed to build multi-part buffers in the buffer copy tests. Already an
+# indirect dependency through opendal, so this adds nothing to the shipped library.
+bytes = "1.12.1"
+
 # This is not optimal. See also the Cargo issue:
 # https://github.com/rust-lang/cargo/issues/1197#issuecomment-1641086954
 [target.'cfg(unix)'.dependencies.opendal]
diff --git a/bindings/dotnet/OpenDAL.Tests/Behavior/ReadBehaviorTest.cs b/bindings/dotnet/OpenDAL.Tests/Behavior/ReadBehaviorTest.cs
index 4aa72ddf1..ce4318173 100644
--- a/bindings/dotnet/OpenDAL.Tests/Behavior/ReadBehaviorTest.cs
+++ b/bindings/dotnet/OpenDAL.Tests/Behavior/ReadBehaviorTest.cs
@@ -65,6 +65,46 @@ public sealed class ReadBehaviorTest : BehaviorTestBase
         Assert.Equal(content, actual);
     }
 
+    [Fact]
+    public void ReadBehavior_LargePayload_RoundTripsExactly()
+    {
+        if (!Supports(c => c.Read && c.Write))
+        {
+            return;
+        }
+
+        // Covers the whole path end to end at a size worth measuring. The chunk
+        // walking itself is pinned by the unit tests in src/buffer.rs, which build
+        // multi-part buffers a backend may or may not produce.
+        const int size = 8 * 1024 * 1024;
+        var path = NewPath("read-large");
+        var content = RandomBytes(size);
+
+        Op.Write(path, content);
+        var actual = Op.Read(path);
+
+        Assert.Equal(content.Length, actual.Length);
+        Assert.True(content.AsSpan().SequenceEqual(actual));
+    }
+
+    [Fact]
+    public async Task ReadBehavior_LargePayload_RoundTripsExactlyAsync()
+    {
+        if (!Supports(c => c.Read && c.Write))
+        {
+            return;
+        }
+
+        var path = NewPath("read-large-async");
+        var content = RandomBytes(8 * 1024 * 1024);
+
+        await Op.WriteAsync(path, content, CT);
+        var actual = await Op.ReadAsync(path, CT);
+
+        Assert.Equal(content.Length, actual.Length);
+        Assert.True(content.AsSpan().SequenceEqual(actual));
+    }
+
     [Fact]
     public void ReadBehavior_WithRange_ReturnsExpectedBytes()
     {
diff --git a/bindings/dotnet/OpenDAL/ByteBuffer.cs b/bindings/dotnet/OpenDAL/Interop/NativeObject/OpenDALBuffer.cs
similarity index 52%
rename from bindings/dotnet/OpenDAL/ByteBuffer.cs
rename to bindings/dotnet/OpenDAL/Interop/NativeObject/OpenDALBuffer.cs
index 8bfab4865..33d8873c9 100644
--- a/bindings/dotnet/OpenDAL/ByteBuffer.cs
+++ b/bindings/dotnet/OpenDAL/Interop/NativeObject/OpenDALBuffer.cs
@@ -19,42 +19,56 @@
 
 using System.Runtime.InteropServices;
 
-namespace OpenDAL;
+namespace OpenDAL.Interop.NativeObject;
 
 [StructLayout(LayoutKind.Sequential)]
 /// <summary>
-/// FFI representation of a Rust byte buffer.
+/// Read payload still owned by the native side.
 /// </summary>
-public struct ByteBuffer
+/// <remarks>
+/// Holds the native buffer itself rather than a flattened copy, so
+/// <see cref="ToManagedBytes"/> can copy straight into its final array. Released
+/// through the owning result's release call, not from here.
+/// </remarks>
+internal struct OpenDALBuffer
 {
     /// <summary>
-    /// Pointer to the first byte in unmanaged memory.
+    /// Opaque handle to the native buffer, or zero when there is no payload.
     /// </summary>
-    public IntPtr Data;
+    public IntPtr Handle;
 
     /// <summary>
-    /// Number of valid bytes in <see cref="Data"/>.
+    /// Total readable bytes across every chunk.
     /// </summary>
     public nuint Len;
 
     /// <summary>
-    /// Total allocated capacity in bytes.
-    /// </summary>
-    public nuint Capacity;
-
-    /// <summary>
-    /// Copies the unmanaged bytes into a managed array.
+    /// Copies the payload into a new managed array.
     /// </summary>
+    /// <returns>The payload, or an empty array when there is none.</returns>
     public readonly unsafe byte[] ToManagedBytes()
     {
-        if (Data == IntPtr.Zero || Len == 0)
+        if (Handle == IntPtr.Zero || Len == 0)
         {
             return Array.Empty<byte>();
         }
 
         var size = checked((int)Len);
         var managed = GC.AllocateUninitializedArray<byte>(size);
-        new ReadOnlySpan<byte>((void*)Data, size).CopyTo(managed);
+        nuint written;
+        fixed (byte* destination = managed)
+        {
+            written = NativeMethods.buffer_copy_to(Handle, destination, (nuint)size);
+        }
+
+        // The array is uninitialized, so a short copy would hand back whatever the
+        // heap held rather than the payload.
+        if (written != (nuint)size)
+        {
+            throw new InvalidOperationException(
+                $"Native buffer reported {Len} bytes but produced {written}.");
+        }
+
         return managed;
     }
-}
\ No newline at end of file
+}
diff --git a/bindings/dotnet/OpenDAL/Interop/Result/OpenDALReadResult.cs b/bindings/dotnet/OpenDAL/Interop/Result/OpenDALReadResult.cs
index 33b89fa21..fdb92ad28 100644
--- a/bindings/dotnet/OpenDAL/Interop/Result/OpenDALReadResult.cs
+++ b/bindings/dotnet/OpenDAL/Interop/Result/OpenDALReadResult.cs
@@ -18,6 +18,7 @@
  */
 
 using System.Runtime.InteropServices;
+using OpenDAL.Interop.NativeObject;
 using OpenDAL.Interop.Result.Abstractions;
 
 namespace OpenDAL.Interop.Result;
@@ -31,7 +32,7 @@ internal struct OpenDALReadResult : INativeValueResult<byte[]>
     /// <summary>
     /// Byte buffer payload on success.
     /// </summary>
-    public ByteBuffer Buffer;
+    public OpenDALBuffer Buffer;
 
     /// <summary>
     /// Error details for the operation.
diff --git a/bindings/dotnet/OpenDAL/NativeMethods.cs b/bindings/dotnet/OpenDAL/NativeMethods.cs
index 2b397bd81..0efa14d1d 100644
--- a/bindings/dotnet/OpenDAL/NativeMethods.cs
+++ b/bindings/dotnet/OpenDAL/NativeMethods.cs
@@ -187,6 +187,18 @@ internal partial class NativeMethods
 
     #endregion
 
+    #region Buffer
+
+    [LibraryImport(__DllName, EntryPoint = "buffer_copy_to")]
+    [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+    internal static unsafe partial nuint buffer_copy_to(
+        IntPtr handle,
+        byte* destination,
+        nuint destinationLen
+    );
+
+    #endregion
+
     #region IO Operations
 
     #region Write
@@ -208,7 +220,8 @@ internal partial class NativeMethods
         Operator op,
         IntPtr executor,
         string path,
-        ByteBuffer data,
+        [In] byte[] data,
+        nuint len,
         IntPtr options,
         delegate* unmanaged[Cdecl]<long, OpenDALResult, void> callback,
         long context
diff --git a/bindings/dotnet/OpenDAL/Operator.cs b/bindings/dotnet/OpenDAL/Operator.cs
index 8bb04631e..0668fa46d 100644
--- a/bindings/dotnet/OpenDAL/Operator.cs
+++ b/bindings/dotnet/OpenDAL/Operator.cs
@@ -269,25 +269,16 @@ public partial class Operator : SafeHandle
         {
             unsafe
             {
-                fixed (byte* ptr = content)
-                {
-                    var buffer = new ByteBuffer
-                    {
-                        Data = (IntPtr)ptr,
-                        Len = (nuint)content.Length,
-                        Capacity = (nuint)content.Length,
-                    };
-
-                    return NativeMethods.operator_write_with_options_async(
-                        this,
-                        executorHandle,
-                        path,
-                        buffer,
-                        optionsHandle,
-                        &OnWriteCompleted,
-                        context
-                    );
-                }
+                return NativeMethods.operator_write_with_options_async(
+                    this,
+                    executorHandle,
+                    path,
+                    content,
+                    (nuint)content.Length,
+                    optionsHandle,
+                    &OnWriteCompleted,
+                    context
+                );
             }
         }
     }
diff --git a/bindings/dotnet/src/buffer.rs b/bindings/dotnet/src/buffer.rs
new file mode 100644
index 000000000..0a015d82e
--- /dev/null
+++ b/bindings/dotnet/src/buffer.rs
@@ -0,0 +1,171 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Read payloads travelling from this crate to the caller.
+//!
+//! Bytes going the other way, such as write payloads, are passed as a plain
+//! pointer and length, because the caller already owns that memory.
+
+use std::ffi::c_void;
+use std::ptr;
+
+#[repr(C)]
+/// Read payload owned by this crate.
+///
+/// Holds the [`opendal::Buffer`] itself rather than a flattened copy, so the
+/// caller can copy straight into its final destination. `handle` must be
+/// released with `opendal_read_result_release` exactly once.
+pub struct OpendalBuffer {
+    /// Leaked `Box<opendal::Buffer>`, or null when there is no payload.
+    pub handle: *mut c_void,
+    /// Total readable bytes across every chunk.
+    pub len: usize,
+}
+
+impl OpendalBuffer {
+    /// Create an empty payload that does not own anything.
+    pub fn empty() -> Self {
+        Self {
+            handle: ptr::null_mut(),
+            len: 0,
+        }
+    }
+
+    /// Take ownership of a buffer without flattening or copying it.
+    pub fn from_buffer(value: opendal::Buffer) -> Self {
+        let len = value.len();
+        if len == 0 {
+            return Self::empty();
+        }
+
+        Self {
+            handle: Box::into_raw(Box::new(value)) as *mut c_void,
+            len,
+        }
+    }
+}
+
+/// Copy the whole payload into `dest`, returning the number of bytes written.
+///
+/// Copies at most `dest_len` bytes. An `opendal::Buffer` may be split across
+/// several chunks, so this walks them and writes each one directly into the
+/// destination; that is the only copy a read performs.
+/// # Safety
+///
+/// - `handle` must be null or come from `OpendalBuffer::from_buffer`.
+/// - `dest` must be valid for writes of `dest_len` bytes.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn buffer_copy_to(
+    handle: *const c_void,
+    dest: *mut u8,
+    dest_len: usize,
+) -> usize {
+    if handle.is_null() || dest.is_null() || dest_len == 0 {
+        return 0;
+    }
+
+    // Iterating consumes the buffer, so work on a clone. `Buffer` is backed by
+    // `Bytes`, meaning this only bumps reference counts.
+    let chunks = unsafe { &*(handle as *const opendal::Buffer) }.clone();
+
+    let mut written = 0usize;
+    for chunk in chunks {
+        if written == dest_len {
+            break;
+        }
+
+        let take = chunk.len().min(dest_len - written);
+        unsafe {
+            ptr::copy_nonoverlapping(chunk.as_ptr(), dest.add(written), take);
+        }
+        written += take;
+    }
+
+    written
+}
+
+/// # Safety
+///
+/// - `handle` must be null or come from `OpendalBuffer::from_buffer`.
+/// - This function must be called at most once for the same handle.
+/// - Callers must not use `handle` after this function returns.
+pub unsafe fn buffer_free(handle: *mut c_void) {
+    if handle.is_null() {
+        return;
+    }
+
+    drop(unsafe { Box::from_raw(handle as *mut opendal::Buffer) });
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Three parts, so the copy has to advance the destination across chunks.
+    /// Backends that hand back a single contiguous buffer never exercise this.
+    fn split_buffer() -> opendal::Buffer {
+        opendal::Buffer::from(vec![
+            bytes::Bytes::from_static(b"abc"),
+            bytes::Bytes::from_static(b"de"),
+            bytes::Bytes::from_static(b"fgh"),
+        ])
+    }
+
+    fn copy(buffer: &opendal::Buffer, dest: &mut [u8]) -> usize {
+        let owned = OpendalBuffer::from_buffer(buffer.clone());
+        let written = unsafe { buffer_copy_to(owned.handle, dest.as_mut_ptr(), dest.len()) };
+        unsafe { buffer_free(owned.handle) };
+        written
+    }
+
+    #[test]
+    fn copies_every_chunk_in_order() {
+        let buffer = split_buffer();
+        let mut dest = vec![0u8; buffer.len()];
+
+        assert_eq!(copy(&buffer, &mut dest), 8);
+        assert_eq!(&dest, b"abcdefgh");
+    }
+
+    #[test]
+    fn stops_at_the_destination_length() {
+        let mut dest = vec![0u8; 4];
+
+        assert_eq!(copy(&split_buffer(), &mut dest), 4);
+        assert_eq!(&dest, b"abcd");
+    }
+
+    #[test]
+    fn honors_a_slice_that_starts_mid_chunk() {
+        // Ranged reads produce exactly this: a non-contiguous buffer whose logical
+        // start sits inside the second part.
+        let buffer = split_buffer().slice(4..7);
+        let mut dest = vec![0u8; buffer.len()];
+
+        assert_eq!(copy(&buffer, &mut dest), 3);
+        assert_eq!(&dest, b"efg");
+    }
+
+    #[test]
+    fn empty_payload_owns_nothing() {
+        let owned = OpendalBuffer::from_buffer(opendal::Buffer::new());
+
+        assert!(owned.handle.is_null());
+        assert_eq!(owned.len, 0);
+        unsafe { buffer_free(owned.handle) };
+    }
+}
diff --git a/bindings/dotnet/src/byte_buffer.rs b/bindings/dotnet/src/byte_buffer.rs
deleted file mode 100644
index d3e8c2678..000000000
--- a/bindings/dotnet/src/byte_buffer.rs
+++ /dev/null
@@ -1,95 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-#[repr(C)]
-/// FFI-safe representation of a Rust `Vec<u8>` buffer.
-///
-/// The buffer ownership is transferred to the caller and must be released by
-/// calling `opendal_read_result_release` exactly once.
-pub struct ByteBuffer {
-    /// Pointer to the start of the allocated bytes.
-    pub data: *mut u8,
-    /// Number of initialized bytes.
-    pub len: usize,
-    /// Total allocated capacity in bytes.
-    pub capacity: usize,
-}
-
-impl ByteBuffer {
-    /// Create an empty buffer that does not own any allocation.
-    pub fn empty() -> Self {
-        Self {
-            data: std::ptr::null_mut(),
-            len: 0,
-            capacity: 0,
-        }
-    }
-
-    /// Convert a vector into a raw FFI buffer without copying.
-    ///
-    /// The returned memory must be released by the C# side via
-    /// `opendal_read_result_release`.
-    pub fn from_vec(mut value: Vec<u8>) -> Self {
-        if value.is_empty() {
-            return Self::empty();
-        }
-
-        let data = value.as_mut_ptr();
-        let len = value.len();
-        let capacity = value.capacity();
-        std::mem::forget(value);
-
-        Self {
-            data,
-            len,
-            capacity,
-        }
-    }
-}
-
-/// # Safety
-///
-/// - `data`, `len`, and `capacity` must come from `ByteBuffer::from_vec`.
-/// - This function must be called at most once for the same allocation.
-/// - Callers must not access `data` after this function returns.
-pub unsafe fn buffer_free(data: *mut u8, len: usize, capacity: usize) {
-    if data.is_null() {
-        debug_assert_eq!(len, 0, "len must be zero when data is null");
-        debug_assert_eq!(capacity, 0, "capacity must be zero when data is null");
-        return;
-    }
-
-    if capacity == 0 {
-        debug_assert!(
-            capacity > 0,
-            "capacity must be greater than zero when data is not null"
-        );
-        return;
-    }
-
-    if capacity < len {
-        debug_assert!(
-            capacity >= len,
-            "capacity must be greater than or equal to len"
-        );
-        return;
-    }
-
-    unsafe {
-        drop(Vec::from_raw_parts(data, len, capacity));
-    }
-}
diff --git a/bindings/dotnet/src/lib.rs b/bindings/dotnet/src/lib.rs
index 42c80c680..88b9a5331 100644
--- a/bindings/dotnet/src/lib.rs
+++ b/bindings/dotnet/src/lib.rs
@@ -20,7 +20,7 @@
 //! This crate exposes `extern "C"` APIs consumed by C# via P/Invoke and keeps
 //! interop memory ownership explicit through dedicated release functions.
 
-mod byte_buffer;
+mod buffer;
 mod capability;
 mod entry;
 mod error;
diff --git a/bindings/dotnet/src/operator.rs b/bindings/dotnet/src/operator.rs
index 4faccd691..656689a61 100644
--- a/bindings/dotnet/src/operator.rs
+++ b/bindings/dotnet/src/operator.rs
@@ -16,7 +16,7 @@
 // under the License.
 
 use crate::{
-    byte_buffer::ByteBuffer,
+    buffer::OpendalBuffer,
     entry::into_entry_list_ptr,
     error::OpenDALError,
     executor::executor_or_default,
@@ -1305,7 +1305,7 @@ pub extern "C" fn operator_input_stream_read_next(
 
 fn operator_input_stream_read_next_inner(
     stream: *mut opendal::blocking::StdBytesIterator,
-) -> Result<ByteBuffer, OpenDALError> {
+) -> Result<OpendalBuffer, OpenDALError> {
     if stream.is_null() {
         return Err(crate::utils::config_invalid_error(
             "input stream pointer is null",
@@ -1323,8 +1323,8 @@ fn operator_input_stream_read_next_inner(
                 err.to_string(),
             ))
         })?
-        .map(|v| ByteBuffer::from_vec(v.to_vec()))
-        .unwrap_or_else(ByteBuffer::empty);
+        .map(|v| OpendalBuffer::from_buffer(v.into()))
+        .unwrap_or_else(OpendalBuffer::empty);
 
     Ok(value)
 }
@@ -1564,31 +1564,37 @@ fn operator_write_with_options_inner(
 ///
 /// - `op` must be a valid operator pointer from `operator_construct`.
 /// - `path` must be a valid null-terminated UTF-8 string.
-/// - `data.data` must be non-null and readable for `data.len` bytes when `data.len > 0`.
+/// - `data` must be non-null and readable for `len` bytes when `len > 0`.
 /// - `callback` must be a valid function pointer and remain callable until invoked.
 #[unsafe(no_mangle)]
 pub unsafe extern "C" fn operator_write_with_options_async(
     op: *const opendal::Operator,
     executor: *const c_void,
     path: *const c_char,
-    data: ByteBuffer,
+    data: *const u8,
+    len: usize,
     options: *const opendal::options::WriteOptions,
     callback: Option<WriteCallback>,
     context: i64,
 ) -> OpendalResult {
     match operator_write_with_options_async_inner(
-        op, executor, path, data, options, callback, context,
+        op, executor, path, data, len, options, callback, context,
     ) {
         Ok(()) => OpendalResult::ok(),
         Err(error) => OpendalResult::from_error(error),
     }
 }
 
+// Mirrors the `extern "C"` signature above, which clippy exempts because a C ABI
+// is not a design choice. This helper exists only to give that function a body it
+// can write with `?`.
+#[allow(clippy::too_many_arguments)]
 fn operator_write_with_options_async_inner(
     op: *const opendal::Operator,
     executor: *const c_void,
     path: *const c_char,
-    data: ByteBuffer,
+    data: *const u8,
+    len: usize,
     options: *const opendal::options::WriteOptions,
     callback: Option<WriteCallback>,
     context: i64,
@@ -1596,7 +1602,7 @@ fn operator_write_with_options_async_inner(
     let op = require_operator(op)?;
     let executor = executor_or_default(executor)?;
     let path = require_cstr(path, "path")?.to_string();
-    require_data_ptr(data.data.cast_const(), data.len)?;
+    require_data_ptr(data, len)?;
     let callback = require_callback(callback)?;
     let options = if options.is_null() {
         opendal::options::WriteOptions::default()
@@ -1604,10 +1610,12 @@ fn operator_write_with_options_async_inner(
         unsafe { (&*options).clone() }
     };
 
-    let payload = if data.len == 0 {
+    // Copied before the task is spawned, so the caller only has to keep its
+    // array alive for the duration of this call.
+    let payload = if len == 0 {
         Vec::new()
     } else {
-        unsafe { std::slice::from_raw_parts(data.data.cast_const(), data.len) }.to_vec()
+        unsafe { std::slice::from_raw_parts(data, len) }.to_vec()
     };
 
     let op = op.clone();
@@ -1656,7 +1664,7 @@ fn operator_read_with_options_inner(
     executor: *const c_void,
     path: *const c_char,
     options: *const opendal::options::ReadOptions,
-) -> Result<ByteBuffer, OpenDALError> {
+) -> Result<OpendalBuffer, OpenDALError> {
     let op = require_operator(op)?;
     let executor = executor_or_default(executor)?;
     let path = require_cstr(path, "path")?;
@@ -1668,10 +1676,9 @@ fn operator_read_with_options_inner(
 
     let value = executor
         .block_on(op.read_options(path, options))
-        .map(|v| v.to_vec())
         .map_err(OpenDALError::from_opendal_error)?;
 
-    Ok(ByteBuffer::from_vec(value))
+    Ok(OpendalBuffer::from_buffer(value))
 }
 
 /// Read bytes from `path` asynchronously with options.
@@ -1722,7 +1729,7 @@ fn operator_read_with_options_async_inner(
         let result = op
             .read_options(&path, options)
             .await
-            .map(|v| ByteBuffer::from_vec(v.to_vec()))
+            .map(OpendalBuffer::from_buffer)
             .map_err(OpenDALError::from_opendal_error);
 
         callback(
diff --git a/bindings/dotnet/src/result.rs b/bindings/dotnet/src/result.rs
index 9568246cf..061d701bd 100644
--- a/bindings/dotnet/src/result.rs
+++ b/bindings/dotnet/src/result.rs
@@ -17,7 +17,7 @@
 
 use std::ffi::c_void;
 
-use crate::byte_buffer::{ByteBuffer, buffer_free};
+use crate::buffer::{OpendalBuffer, buffer_free};
 use crate::entry::entry_list_free;
 use crate::error::OpenDALError;
 use crate::metadata::metadata_free;
@@ -75,7 +75,7 @@ pub struct OpendalEntryListResult {
 #[repr(C)]
 /// Result for operations returning a byte buffer payload.
 pub struct OpendalReadResult {
-    pub buffer: ByteBuffer,
+    pub buffer: OpendalBuffer,
     pub error: OpenDALError,
 }
 
@@ -164,8 +164,8 @@ define_result!(
 
 define_result!(
     OpendalReadResult,
-    field = buffer: ByteBuffer,
-    error_value = ByteBuffer::empty()
+    field = buffer: OpendalBuffer,
+    error_value = OpendalBuffer::empty()
 );
 
 define_result!(
@@ -255,18 +255,12 @@ pub extern "C" fn opendal_entry_list_result_release(mut result: OpendalEntryList
 /// This function is idempotent for empty/null buffers.
 /// # Safety
 ///
-/// - `result.buffer` must originate from `ByteBuffer::from_vec` in this crate.
-/// - The buffer memory must not be accessed after this call.
+/// - `result.buffer` must originate from `OpendalBuffer::from_buffer` in this crate.
+/// - The buffer must not be accessed after this call.
 #[unsafe(no_mangle)]
 pub extern "C" fn opendal_read_result_release(mut result: OpendalReadResult) {
-    if !result.buffer.data.is_null() {
-        unsafe {
-            buffer_free(
-                result.buffer.data,
-                result.buffer.len,
-                result.buffer.capacity,
-            );
-        }
+    unsafe {
+        buffer_free(result.buffer.handle);
     }
 
     release_error_message(&mut result.error);