Repository: pyodide/pyodide
Issue category: Performance Issue

Performance issues with buffer conversions from python to javascript
Hello @hoodmane, thank you for the magnificent APIs (deep/shallowCopyToJavaScript) that would eliminate the heavy cost of big array conversion from python to Javascript.  I think the shallowCopy version is designed for memory address reference instead of bulks of content copy. But I find the perfomance not so good as I've imagined, which is displayed as the following:
![image](https://user-images.githubusercontent.com/34263430/107121264-74600900-68cc-11eb-8e92-da3b8b09b5f4.png)

It takes about 1~7 seconds for shallowCopyToJavascript() to complete the memory address reference and maybe some necessary meta data copy I guess. However, it's not  adequate for a realtime computation.  Any suggestions for better conversion performance?

_Originally posted by @daoxian in https://github.com/iodide-project/pyodide/issues/1167#issuecomment-774488338_


Functions to edit:
docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py:PyodideAnalyzer.get_object_from_json
docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py:PyodideAnalyzer.create_js_doclets
docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py:JsDocContent.get_rst
docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py:JsDocSummary.run

Patch:
diff --git a/Makefile b/Makefile
index 65ef0473d75..399ed3f5a7c 100644
--- a/Makefile
+++ b/Makefile
@@ -114,7 +114,7 @@ clean-all: clean
 	make -C cpython clean
 	rm -fr cpython/build
 
-%.o: %.c $(CPYTHONLIB) $(wildcard src/**/*.h)
+%.o: %.c $(CPYTHONLIB) $(wildcard src/**/*.h src/**/*.js)
 	$(CC) -o $@ -c $< $(MAIN_MODULE_CFLAGS) -Isrc/core/
 
 
diff --git a/docs/project/changelog.md b/docs/project/changelog.md
index 92912bc05f5..b0954297191 100644
--- a/docs/project/changelog.md
+++ b/docs/project/changelog.md
@@ -53,6 +53,9 @@ substitutions:
   [#1175](https://github.com/iodide-project/pyodide/pull/1175)
 - {{ API }} The `pyodide.pyimport` function is deprecated in favor of using
   `pyodide.globals.get('key')`. [#1367](https://github.com/iodide-project/pyodide/pull/1367)
+- {{ API }} Added `PyProxy.getBuffer` API to allow direct access to Python
+  buffers as Javascript TypedArrays.
+  [1215](https://github.com/iodide-project/pyodide/pull/1215)
 
 ### Fixed
 - {{ Fix }} getattr and dir on JsProxy now report consistent results and include all
diff --git a/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py b/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py
index e20cfcfb3cf..8cee12f7664 100644
--- a/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py
+++ b/docs/sphinx_pyodide/sphinx_pyodide/jsdoc.py
@@ -4,6 +4,7 @@
 from docutils.utils import new_document
 
 from collections import OrderedDict
+import re
 
 from sphinx import addnodes
 from sphinx.util import rst
@@ -11,9 +12,13 @@
 from sphinx.ext.autosummary import autosummary_table, extract_summary
 
 from sphinx_js.jsdoc import Analyzer as JsAnalyzer
-from sphinx_js.ir import Function
+from sphinx_js.ir import Class, Function
 from sphinx_js.parsers import path_and_formal_params, PathVisitor
-from sphinx_js.renderers import AutoFunctionRenderer, AutoAttributeRenderer
+from sphinx_js.renderers import (
+    AutoFunctionRenderer,
+    AutoAttributeRenderer,
+    AutoClassRenderer,
+)
 
 
 class PyodideAnalyzer:
@@ -47,7 +52,12 @@ def get_object_from_json(self, json):
         path components which JsAnalyzer.get_object requires.
         """
         path = self.longname_to_path(json["longname"])
-        kind = "function" if json["kind"] == "function" else "attribute"
+        if json["kind"] == "function":
+            kind = "function"
+        elif json["kind"] == "class":
+            kind = "class"
+        else:
+            kind = "attribute"
         obj = self.inner.get_object(path, kind)
         obj.kind = kind
         return obj
@@ -58,12 +68,16 @@ def create_js_doclets(self):
         """
 
         def get_val():
-            return OrderedDict([["attribute", []], ["function", []]])
+            return OrderedDict([["attribute", []], ["function", []], ["class", []]])
 
         self.js_docs = {key: get_val() for key in ["globals", "pyodide", "PyProxy"]}
         items = {"PyProxy": []}
         for (key, group) in self._doclets_by_class.items():
             key = [x for x in key if "/" not in x]
+            if key[-1] == "PyBuffer":
+                # PyBuffer stuff is documented as a class. Would be nice to have
+                # a less ad hoc way to deal with this...
+                continue
             if key[-1] == "globalThis":
                 items["globals"] = group
             if key[0] == "pyodide." and key[-1] == "Module":
@@ -76,7 +90,13 @@ def get_val():
                 if json.get("access", None) == "private":
                     continue
                 obj = self.get_object_from_json(json)
+                if isinstance(obj, Class):
+                    # sphinx-jsdoc messes up array types. Fix them.
+                    for x in obj.members:
+                        if hasattr(x, "type"):
+                            x.type = re.sub("Array\.<([a-zA-Z_0-9]*)>", r"\1[]", x.type)
                 if obj.name[0] == '"' and obj.name[-1] == '"':
+                    # sphinx-jsdoc messes up Symbol attributes. Fix them.
                     obj.name = "[" + obj.name[1:-1] + "]"
                 self.js_docs[key][obj.kind].append(obj)
 
@@ -97,11 +117,13 @@ def get_rst(self, obj):
             JsDoc also has an AutoClassRenderer which may be useful in the future."""
             if isinstance(obj, Function):
                 renderer = AutoFunctionRenderer
+            elif isinstance(obj, Class):
+                renderer = AutoClassRenderer
             else:
                 renderer = AutoAttributeRenderer
-            return renderer(self, app, arguments=["dummy"]).rst(
-                [obj.name], obj, use_short_name=False
-            )
+            return renderer(
+                self, app, arguments=["dummy"], options={"members": ["*"]}
+            ).rst([obj.name], obj, use_short_name=False)
 
         def get_rst_for_group(self, objects):
             return [self.get_rst(obj) for obj in objects]
@@ -144,6 +166,9 @@ def run(self):
             for group_name, group_objects in value.items():
                 if not group_objects:
                     continue
+                if group_name == "class":
+                    # Plural of class is "classes" not "classs"
+                    group_name += "e"
                 result.append(self.format_heading(group_name.title() + "s:"))
                 table_items = self.get_summary_table(module, group_objects)
                 table_markup = self.format_table(table_items)
diff --git a/docs/usage/type-conversions.md b/docs/usage/type-conversions.md
index bdda6d1d506..5f2342f0abc 100644
--- a/docs/usage/type-conversions.md
+++ b/docs/usage/type-conversions.md
@@ -369,15 +369,30 @@ numpy_array = np.asarray(array)
 
 ### Converting Python Buffer objects to Javascript
 
-Python `bytes` and `buffer` objects are translated to Javascript as
-`TypedArray`s without any memory copy at all. This conversion is thus very
-efficient, but be aware that any changes to the buffer will be reflected in both
-places.
-
-Numpy arrays are currently converted to Javascript as nested (regular) Arrays. A
-more efficient method will probably emerge as we decide on an ndarray
-implementation for Javascript.
-
+A PyProxy of any Python object supporting the
+[Python Buffer protocol](https://docs.python.org/3/c-api/buffer.html) will have
+a method called :any`getBuffer`. This can be used to retrieve a reference to a
+Javascript typed array that points to the data backing the Python object,
+combined with other metadata about the buffer format. The metadata is suitable
+for use with a Javascript ndarray library if one is present. For instance, if
+you load the Javascript [ndarray](https://github.com/scijs/ndarray)
+package, you can do:
+```js
+let proxy = pyodide.globals.get("some_numpy_ndarray");
+let buffer = proxy.getBuffer();
+proxy.destroy();
+try {
+    if(buffer.readonly){
+        // We can't stop you from changing a readonly buffer, but it can cause undefined behavior.
+        throw new Error("Uh-oh, we were planning to change the buffer");
+    }
+    let array = new ndarray(buffer.data, buffer.shape, buffer.strides, buffer.offset);
+    // manipulate array here
+    // changes will be reflected in the Python ndarray!
+} finally {
+    buffer.release(); // Release the memory when we're done
+}
+```
 
 ## Importing Python objects into Javascript
 
diff --git a/src/core/pyproxy.c b/src/core/pyproxy.c
index 0fbeb9d5541..5887cf91a93 100644
--- a/src/core/pyproxy.c
+++ b/src/core/pyproxy.c
@@ -621,6 +621,154 @@ _pyproxy_ensure_future(PyObject* pyobject,
   return success ? 0 : -1;
 }
 
+///////////////////////////////////////////////////////////////////////////////
+//
+// Buffers
+//
+
+// For debug
+size_t py_buffer_len_offset = offsetof(Py_buffer, len);
+size_t py_buffer_shape_offset = offsetof(Py_buffer, shape);
+
+/**
+ * Convert a C array of Py_ssize_t to Javascript.
+ */
+EM_JS_REF(JsRef, array_to_js, (Py_ssize_t * array, int len), {
+  return Module.hiwire.new_value(
+    Array.from(HEAP32.subarray(array / 4, array / 4 + len)));
+})
+
+// The order of these fields has to match the code in getBuffer
+typedef struct
+{
+  // where is the first entry buffer[0]...[0] (ndim times)?
+  void* start_ptr;
+  // Where is the earliest location in buffer? (If all strides are positive,
+  // this equals start_ptr)
+  void* smallest_ptr;
+  // What is the last location in the buffer (plus one)
+  void* largest_ptr;
+
+  int readonly;
+  char* format;
+  int itemsize;
+  JsRef shape;
+  JsRef strides;
+
+  Py_buffer* view;
+  int c_contiguous;
+  int f_contiguous;
+} buffer_struct;
+
+/**
+ * This is the C part of the getBuffer method.
+ *
+ * We use PyObject_GetBuffer to acquire a Py_buffer view to the object, then we
+ * determine the locations of: the first element of the buffer, the earliest
+ * element of the buffer in memory the lastest element of the buffer in memory
+ * (plus one itemsize).
+ *
+ * We will use this information to slice out a subarray of the wasm heap that
+ * contains all the memory inside of the buffer.
+ *
+ * Special care must be taken for negative strides, this is why we need to keep
+ * track separately of start_ptr (the location of the first element of the
+ * array) and smallest_ptr (the location of the earliest element of the array in
+ * memory). If strides are positive, these are the same but if some strides are
+ * negative they will be different.
+ *
+ * We also put the various other metadata about the buffer that we want to share
+ * into buffer_struct.
+ */
+buffer_struct*
+_pyproxy_get_buffer(PyObject* ptrobj)
+{
+  if (!PyObject_CheckBuffer(ptrobj)) {
+    return NULL;
+  }
+  Py_buffer view;
+  // PyBUF_RECORDS_RO requires that suboffsets be NULL but otherwise is the most
+  // permissive possible request.
+  if (PyObject_GetBuffer(ptrobj, &view, PyBUF_RECORDS_RO) == -1) {
+    // Buffer cannot be represented without suboffsets. The bf_getbuffer method
+    // should have set a PyExc_BufferError saying something to this effect.
+    pythonexc2js();
+  }
+
+  bool success = false;
+  buffer_struct result = { 0 };
+  result.start_ptr = result.smallest_ptr = result.largest_ptr = view.buf;
+  result.readonly = view.readonly;
+
+  result.format = view.format;
+  result.itemsize = view.itemsize;
+
+  if (view.ndim == 0) {
+    // "If ndim is 0, buf points to a single item representing a scalar. In this
+    // case, shape, strides and suboffsets MUST be NULL."
+    // https://docs.python.org/3/c-api/buffer.html#c.Py_buffer.ndim
+    result.largest_ptr += view.itemsize;
+    result.shape = hiwire_array();
+    result.strides = hiwire_array();
+    result.c_contiguous = true;
+    result.f_contiguous = true;
+    goto success;
+  }
+
+  // Because we requested PyBUF_RECORDS_RO I think we can assume that
+  // view.shape != NULL.
+  result.shape = array_to_js(view.shape, view.ndim);
+
+  if (view.strides == NULL) {
+    // In this case we are a C contiguous buffer
+    result.largest_ptr += view.len;
+    Py_ssize_t strides[view.ndim];
+    PyBuffer_FillContiguousStrides(
+      view.ndim, view.shape, strides, view.itemsize, 'C');
+    result.strides = array_to_js(strides, view.ndim);
+    goto success;
+  }
+
+  if (view.len != 0) {
+    // Have to be careful to ensure that we handle negative strides correctly.
+    for (int i = 0; i < view.ndim; i++) {
+      // view.strides[i] != 0
+      if (view.strides[i] > 0) {
+        // add positive strides to largest_ptr
+        result.largest_ptr += view.strides[i] * (view.shape[i] - 1);
+      } else {
+        // subtract negative strides from smallest_ptr
+        result.smallest_ptr += view.strides[i] * (view.shape[i] - 1);
+      }
+    }
+    result.largest_ptr += view.itemsize;
+  }
+
+  result.strides = array_to_js(view.strides, view.ndim);
+  result.c_contiguous = PyBuffer_IsContiguous(&view, 'C');
+  result.f_contiguous = PyBuffer_IsContiguous(&view, 'F');
+
+success:
+  success = true;
+finally:
+  if (success) {
+    // The result.view memory will be freed when (if?) the user calls
+    // Py_Buffer.release().
+    result.view = (Py_buffer*)PyMem_Malloc(sizeof(Py_buffer));
+    *result.view = view;
+    // The result_heap memory will be freed by getBuffer
+    buffer_struct* result_heap =
+      (buffer_struct*)PyMem_Malloc(sizeof(buffer_struct));
+    *result_heap = result;
+    return result_heap;
+  } else {
+    hiwire_CLEAR(result.shape);
+    hiwire_CLEAR(result.strides);
+    PyBuffer_Release(&view);
+    return NULL;
+  }
+}
+
 ///////////////////////////////////////////////////////////////////////////////
 //
 // Javascript code
@@ -686,8 +834,6 @@ EM_JS_REF(JsRef, pyproxy_new, (PyObject * ptrobj), {
   return Module.hiwire.new_value(proxy);
 });
 
-// clang-format on
-
 EM_JS_REF(JsRef, create_once_callable, (PyObject * obj), {
   _Py_IncRef(obj);
   let alreadyCalled = false;
@@ -807,6 +953,10 @@ static PyMethodDef pyproxy_methods[] = {
 #define TEMP_EMJS_HELPER(a, args...)                                           \
   EM_JS_NUM(int, pyproxy_init_js, (), UNPAIRED_OPEN_BRACE { args return 0; })
 
+// A macro to allow us to add code that is only intended to influence JsDoc
+// output, but shouldn't end up in generated code.
+#define FOR_JSDOC_ONLY(x)
+
 #include "pyproxy.js"
 
 #undef TEMP_EMJS_HELPER
diff --git a/src/core/pyproxy.js b/src/core/pyproxy.js
index b6ae265dd37..49d40495242 100644
--- a/src/core/pyproxy.js
+++ b/src/core/pyproxy.js
@@ -662,7 +662,326 @@ TEMP_EMJS_HELPER(() => {0, /* Magic, see comment */
   };
 
   Module.PyProxyCallableMethods = {prototype : Function.prototype};
-  Module.PyProxyBufferMethods = {};
+
+  let type_to_array_map = new Map([
+    [ "i8", Int8Array ],
+    [ "u8", Uint8Array ],
+    [ "i16", Int16Array ],
+    [ "u16", Uint16Array ],
+    [ "i32", Int32Array ],
+    [ "u32", Uint32Array ],
+    [ "i32", Int32Array ],
+    [ "u32", Uint32Array ],
+    [ "f32", Float32Array ],
+    [ "f64", Float64Array ],
+    // Python type formats
+    [ "b", Int8Array ],
+    [ "B", Uint8Array ],
+    [ "h", Int16Array ],
+    [ "H", Uint16Array ],
+    [ "i", Int32Array ],
+    [ "I", Uint32Array ],
+    [ "f", Float32Array ],
+    [ "d", Float64Array ],
+  ]);
+
+  if (globalThis.BigInt64Array) {
+    type_to_array_map.set("i64", BigInt64Array);
+    type_to_array_map.set("u64", BigUint64Array);
+    type_to_array_map.set("q", BigInt64Array);
+    type_to_array_map.set("Q", BigUint64Array);
+  }
+
+  Module.PyProxyBufferMethods = {
+    /**
+     * Get a view of the buffer data which is usable from Javascript. No copy is
+     * ever performed.
+     *
+     * The return value is a :any:`PyBuffer` object. See the documentation for
+     * :any:`PyBuffer` for details on how to use it.
+     *
+     * We do not support suboffsets, if the buffer requires suboffsets we will
+     * throw an error. Javascript nd array libraries can't handle suboffsets
+     * anyways. In this case, you should copy the buffer to one that doesn't use
+     * suboffets (using e.g., ``np.ascontiguousarray``).
+     *
+     * @param {string} type The type of the desired output. Should be one of:
+     *    "i8", "u8", "i16", "u16", "i32", "u32", "i32", "u32", "i64", "u64",
+     *    "f32", or "f64,
+     * @returns PyBuffer
+     */
+    getBuffer : function(type = "u8") {
+      let ArrayType = undefined;
+      if (type) {
+        let ArrayType = type_to_array_map.get(type);
+        if (ArrayType === undefined) {
+          throw new Error(`Unknown type ${type}`);
+        }
+      }
+      let this_ptr = _getPtr(this);
+      let buffer_struct_ptr = __pyproxy_get_buffer(this_ptr);
+      if (buffer_struct_ptr === 0) {
+        throw new Error("Failed");
+      }
+
+      // This has to match the order of the fields in buffer_struct
+      let cur_ptr = buffer_struct_ptr / 4;
+
+      let startByteOffset = HEAP32[cur_ptr++];
+      let minByteOffset = HEAP32[cur_ptr++];
+      let maxByteOffset = HEAP32[cur_ptr++];
+
+      let readonly = !!HEAP32[cur_ptr++];
+      let format_ptr = HEAP32[cur_ptr++];
+      let itemsize = HEAP32[cur_ptr++];
+      let shape = Module.hiwire.pop_value(HEAP32[cur_ptr++]);
+      let strides = Module.hiwire.pop_value(HEAP32[cur_ptr++]);
+
+      let view_ptr = HEAP32[cur_ptr++];
+      let c_contiguous = !!HEAP32[cur_ptr++];
+      let f_contiguous = !!HEAP32[cur_ptr++];
+
+      _PyMem_Free(buffer_struct_ptr);
+
+      let format = UTF8ToString(format_ptr);
+
+      let success = false;
+      try {
+        if (ArrayType === undefined) {
+          // Try to determine correct type from format.
+          // To understand this code it will be helpful to look at the tables
+          // here: https://docs.python.org/3/library/struct.html#format-strings
+          if (format.includes("e")) {
+            throw new Error("Javascript has no Float16Array.");
+          }
+          let cleaned_format = format;
+          // Normalize same-sized types
+          cleaned_format = cleaned_format.replace(/[spc?]/g, "B");
+          cleaned_format = cleaned_format.replace(/[nl]/g, "i");
+          cleaned_format = cleaned_format.replace(/[NLP]/g, "I");
+          let type_char = cleaned_format[0];
+          ArrayType = type_to_array_map.get(type_char);
+          if (ArrayType === undefined) {
+            if (/[qQ]/.test(type_char)) {
+              throw new Error(
+                  "64 bit integer formats (q and Q) are not supported in browsers without BigInt support. You must pass a type argument.");
+            } else {
+              throw new Error(
+                  "Unrecognized buffer format. You must pass a type argument.");
+            }
+          }
+        }
+
+        let alignment = parseInt(ArrayType.name.replace(/[^0-9]/g, "")) / 8;
+        if (startByteOffset % alignment !== 0 ||
+            minByteOffset % alignment !== 0 ||
+            maxByteOffset % alignment !== 0) {
+          throw new Error(
+              `Buffer does not have valid alignment for a ${ArrayType.name}`);
+        }
+        let numBytes = maxByteOffset - minByteOffset;
+        let numEntries = numBytes / alignment;
+        let offset = (startByteOffset - minByteOffset) / alignment;
+        let data = new ArrayType(HEAP8.buffer, minByteOffset, numEntries);
+        for (let i of strides.keys()) {
+          strides[i] /= alignment;
+        }
+
+        success = true;
+        // clang-format off
+        return Object.create(Module.PyBuffer.prototype,
+          Object.getOwnPropertyDescriptors({
+            offset,
+            readonly,
+            format,
+            itemsize,
+            ndim : shape.length,
+            nbytes : numBytes,
+            shape,
+            strides,
+            data,
+            c_contiguous,
+            f_contiguous,
+            _view_ptr : view_ptr,
+            _released : false
+          })
+        );
+        // clang-format on
+      } finally {
+        if (!success) {
+          _PyBuffer_Release(view_ptr);
+          _PyMem_Free(view_ptr);
+        }
+      }
+    }
+  };
+
+  /**
+   * A class to allow access to a Python data buffers from Javascript. These are
+   * produced by :any:`PyProxy.getBuffer` and cannot be constructed directly.
+   * When you are done, release it with the :any:`release <PyBuffer.release>`
+   * method.  See
+   * `Python buffer protocol documentation
+   * <https://docs.python.org/3/c-api/buffer.html>`_ for more information.
+   *
+   * To find the element ``x[a_1, ..., a_n]``, you could use the following code:
+   *
+   * .. code-block:: js
+   *
+   *    function multiIndexToIndex(pybuff, multiIndex){
+   *       if(multindex.length !==pybuff.ndim){
+   *          throw new Error("Wrong length index");
+   *       }
+   *       let idx = pybuff.offset;
+   *       for(let i = 0; i < pybuff.ndim; i++){
+   *          if(multiIndex[i] < 0){
+   *             multiIndex[i] = pybuff.shape[i] - multiIndex[i];
+   *          }
+   *          if(multiIndex[i] < 0 || multiIndex[i] >= pybuff.shape[i]){
+   *             throw new Error("Index out of range");
+   *          }
+   *          idx += multiIndex[i] * pybuff.stride[i];
+   *       }
+   *       return idx;
+   *    }
+   *    console.log("entry is", pybuff.data[multiIndexToIndex(pybuff, [2, 0,
+   * -1])]);
+   *
+   * .. admonition:: Contiguity
+   *    :class: warning
+   *
+   *    If the buffer is not contiguous, the ``data`` TypedArray will contain
+   * data that is not part of the buffer. Modifying this data may lead to
+   * undefined behavior.
+   *
+   * .. admonition:: Readonly buffers
+   *    :class: warning
+   *
+   *    If ``buffer.readonly`` is ``true``, you should not modify the buffer.
+   *    Modifying a readonly buffer may lead to undefined behavior.
+   *
+   * .. admonition:: Converting between TypedArray types
+   *    :class: warning
+   *
+   *    The following naive code to change the type of a typed array does not
+   *    work:
+   *
+   *    .. code-block:: js
+   *
+   *        // Incorrectly convert a TypedArray.
+   *        // Produces a Uint16Array that points to the entire WASM memory!
+   *        let myarray = new Uint16Array(buffer.data.buffer);
+   *
+   *    Instead, if you want to convert the output TypedArray, you need to say:
+   *
+   *    .. code-block:: js
+   *
+   *        // Correctly convert a TypedArray.
+   *        let myarray = new Uint16Array(
+   *            buffer.data.buffer,
+   *            buffer.data.byteOffset,
+   *            buffer.data.byteLength
+   *        );
+   */
+  Module.PyBuffer = class PyBuffer {
+    constructor() {
+      // FOR_JSDOC_ONLY is a macro that deletes its argument.
+      FOR_JSDOC_ONLY(() => {
+        /**
+         * The offset of the first entry of the array. For instance if our array
+         * is 3d, then you will find ``array[0,0,0]`` at
+         * ``pybuf.data[pybuf.offset]``
+         * @type {number}
+         */
+        this.offset;
+
+        /**
+         * If the data is readonly, you should not modify it. There is no way
+         * for us to enforce this, but it may cause very weird behavior.
+         * @type {boolean}
+         */
+        this.readonly;
+
+        /**
+         * The format string for the buffer. See `the Python documentation on
+         * format strings
+         * <https://docs.python.org/3/library/struct.html#format-strings>`_.
+         * @type {string}
+         */
+        this.format;
+
+        /**
+         * How large is each entry (in bytes)?
+         * @type {number}
+         */
+        this.itemsize;
+
+        /**
+         * The number of dimensions of the buffer. If ``ndim`` is 0, the buffer
+         * represents a single scalar or struct. Otherwise, it represents an
+         * array.
+         * @type {number}
+         */
+        this.ndim;
+
+        /**
+         * The total number of bytes the buffer takes up. This is equal to
+         * ``buff.data.byteLength``.
+         * @type {number}
+         */
+        this.nbytes;
+
+        /**
+         * The shape of the buffer, that is how long it is in each dimension.
+         * The length will be equal to ``ndim``. For instance, a 2x3x4 array
+         * would have shape ``[2, 3, 4]``.
+         * @type {number[]}
+         */
+        this.shape;
+
+        /**
+         * An array of of length ``ndim`` giving the number of elements to skip
+         * to get to a new element in each dimension. See the example definition
+         * of a ``multiIndexToIndex`` function above.
+         * @type {number[]}
+         */
+        this.strides;
+
+        /**
+         * The actual data. A typed array of an appropriate size backed by a
+         * segment of the WASM memory.
+         * @type {TypedArray}
+         */
+        this.data;
+
+        /**
+         * Is it C contiguous?
+         * @type {boolean}
+         */
+        this.c_contiguous;
+
+        /**
+         * Is it Fortran contiguous?
+         * @type {boolean}
+         */
+        this.f_contiguous;
+      });
+      throw new TypeError('PyBuffer is not a constructor');
+    }
+
+    /**
+     * Release the buffer. This allows the memory to be reclaimed.
+     */
+    release() {
+      if (this._released) {
+        return;
+      }
+      _PyBuffer_Release(this._view_ptr);
+      _PyMem_Free(this._view_ptr);
+      this._released = true;
+      this.data = null;
+    }
+  };
 
   // A special proxy that we use to wrap pyodide.globals to allow property
   // access like `pyodide.globals.x`.

Test patch: diff --git a/packages/numpy/test_numpy.py b/packages/numpy/test_numpy.py
index 6f1e54d1d40..116bc2808ee 100644
--- a/packages/numpy/test_numpy.py
+++ b/packages/numpy/test_numpy.py
@@ -1,3 +1,6 @@
+import pytest
+
+
 def test_numpy(selenium):
     selenium.load_package("numpy")
     selenium.run("import numpy")
@@ -191,3 +194,107 @@ def test_runwebworker_numpy(selenium_standalone):
         """
     )
     assert output == "[0. 0. 0. 0. 0.]"
+
+
+def test_get_buffer(selenium):
+    selenium.run_js(
+        """
+        await pyodide.runPythonAsync(`
+            import numpy as np
+            x = np.arange(24)
+            z1 = x.reshape([8,3])
+            z2 = z1[-1::-1]
+            z3 = z1[::,-1::-1]
+            z4 = z1[-1::-1,-1::-1]
+        `);
+        for(let x of ["z1", "z2", "z3", "z4"]){
+            let z = pyodide.pyimport(x).getBuffer("u32");
+            for(let idx1 = 0; idx1 < 8; idx1++) {
+                for(let idx2 = 0; idx2 < 3; idx2++){
+                    let v1 = z.data[z.offset + z.strides[0] * idx1 + z.strides[1] * idx2];
+                    let v2 = pyodide.runPython(`repr(${x}[${idx1}, ${idx2}])`);
+                    console.log(`${v1}, ${typeof(v1)}, ${v2}, ${typeof(v2)}, ${v1===v2}`);
+                    if(v1.toString() !== v2){
+                        throw new Error(`Discrepancy ${x}[${idx1}, ${idx2}]: ${v1} != ${v2}`);
+                    }
+                }
+            }
+            z.release();
+        }
+        """
+    )
+
+
+@pytest.mark.parametrize(
+    "arg",
+    [
+        "np.arange(6).reshape((2, -1))",
+        "np.arange(12).reshape((3, -1))[::2, ::2]",
+        "np.arange(12).reshape((3, -1))[::-1, ::-1]",
+        "np.arange(12).reshape((3, -1))[::, ::-1]",
+        "np.arange(12).reshape((3, -1))[::-1, ::]",
+        "np.arange(12).reshape((3, -1))[::-2, ::-2]",
+        "np.arange(6).reshape((2, -1)).astype(np.int8, order='C')",
+        "np.arange(6).reshape((2, -1)).astype(np.int8, order='F')",
+        "np.arange(6).reshape((2, -1, 1))",
+        "np.ones((1, 1))[0:0]",  # shape[0] == 0
+        "np.ones(1)",  # ndim == 0
+    ]
+    + [
+        f"np.arange(3).astype(np.{type_})"
+        for type_ in ["int8", "uint8", "int16", "int32", "float32", "float64"]
+    ],
+)
+def test_get_buffer_roundtrip(selenium, arg):
+    selenium.run_js(
+        f"""
+        await pyodide.runPythonAsync(`
+            import numpy as np
+            x = {arg}
+        `);
+        window.x_js_buf = pyodide.pyimport("x").getBuffer();
+        x_js_buf.length = x_js_buf.data.length;
+        """
+    )
+
+    selenium.run_js(
+        """
+        pyodide.runPython(`
+            import itertools
+            from unittest import TestCase
+            from js import x_js_buf
+            assert_equal = TestCase().assertEqual
+
+            assert_equal(x_js_buf.ndim, x.ndim)
+            assert_equal(x_js_buf.shape.to_py(), list(x.shape))
+            assert_equal(x_js_buf.strides.to_py(), [s/x.itemsize for s in x.data.strides])
+            assert_equal(x_js_buf.format, x.data.format)
+            if len(x) == 0:
+                assert x_js_buf.length == 0
+            else:
+                minoffset = 1000
+                maxoffset = 0
+                for tup in itertools.product(*[range(n) for n in x.shape]):
+                    offset = x_js_buf.offset + sum(x*y for (x,y) in zip(tup, x_js_buf.strides))
+                    minoffset = min(offset, minoffset)
+                    maxoffset = max(offset, maxoffset)
+                    assert_equal(x[tup], x_js_buf.data[offset])
+                assert_equal(minoffset, 0)
+                assert_equal(maxoffset + 1, x_js_buf.length)
+            x_js_buf.release()
+        `);
+        """
+    )
+
+
+def test_get_buffer_error_messages(selenium):
+    with pytest.raises(Exception, match="Javascript has no Float16Array"):
+        selenium.run_js(
+            """
+            await pyodide.runPythonAsync(`
+                import numpy as np
+                x = np.ones(2, dtype=np.float16)
+            `);
+            pyodide.pyimport("x").getBuffer();
+            """
+        )
diff --git a/src/tests/test_pyproxy.py b/src/tests/test_pyproxy.py
index 75e7b750bda..d0a34a6db9a 100644
--- a/src/tests/test_pyproxy.py
+++ b/src/tests/test_pyproxy.py
@@ -198,6 +198,40 @@ def test():
     assert result == result2
 
 
+def test_pyproxy_get_buffer(selenium):
+    selenium.run_js(
+        """
+        await pyodide.runPython(`
+            from sys import getrefcount
+            z1 = memoryview(bytes(range(24))).cast("b", [8,3])
+            z2 = z1[-1::-1]
+        `);
+        for(let x of ["z1", "z2"]){
+            pyodide.runPython(`assert getrefcount(${x}) == 2`);
+            let proxy = pyodide.globals.get(x);
+            pyodide.runPython(`assert getrefcount(${x}) == 3`);
+            let z = proxy.getBuffer();
+            pyodide.runPython(`assert getrefcount(${x}) == 4`);
+            proxy.destroy();
+            pyodide.runPython(`assert getrefcount(${x}) == 3`);
+            for(let idx1 = 0; idx1 < 8; idx1++) {
+                for(let idx2 = 0; idx2 < 3; idx2++){
+                    let v1 = z.data[z.offset + z.strides[0] * idx1 + z.strides[1] * idx2];
+                    let v2 = pyodide.runPython(`repr(${x}[${idx1}, ${idx2}])`);
+                    console.log(`${v1}, ${typeof(v1)}, ${v2}, ${typeof(v2)}, ${v1===v2}`);
+                    if(v1.toString() !== v2){
+                        throw new Error(`Discrepancy ${x}[${idx1}, ${idx2}]: ${v1} != ${v2}`);
+                    }
+                }
+            }
+            z.release();
+            pyodide.runPython(`assert getrefcount(${x}) == 2`);
+            pyodide.runPython(`del ${x}`);
+        }
+        """
+    )
+
+
 def test_pyproxy_mixins(selenium):
     result = selenium.run_js(
         """