Implementing the buffer protocol

Cython objects can expose memory buffers to Python codeby implementing the “buffer protocol”.This chapter shows how to implement the protocoland make use of the memory managed by an extension type from NumPy.

A matrix class

The following Cython/C++ code implements a matrix of floats,where the number of columns is fixed at construction timebut rows can be added dynamically.

  1. # distutils: language = c++
  2.  
  3. # matrix.pyx
  4.  
  5. from libcpp.vector cimport vector
  6.  
  7. cdef class Matrix:
  8. cdef unsigned ncols
  9. cdef vector[float] v
  10.  
  11. def __cinit__(self, unsigned ncols):
  12. self.ncols = ncols
  13.  
  14. def add_row(self):
  15. """Adds a row, initially zero-filled."""
  16. self.v.resize(self.v.size() + self.ncols)

There are no methods to do anything productive with the matrices’ contents.We could implement custom getitem, setitem, etc. for this,but instead we’ll use the buffer protocol to expose the matrix’s data to Pythonso we can use NumPy to do useful work.

Implementing the buffer protocol requires adding two methods,getbuffer and releasebuffer,which Cython handles specially.

  1. # distutils: language = c++
  2.  
  3. from cpython cimport Py_buffer
  4. from libcpp.vector cimport vector
  5.  
  6. cdef class Matrix:
  7. cdef Py_ssize_t ncols
  8. cdef Py_ssize_t shape[2]
  9. cdef Py_ssize_t strides[2]
  10. cdef vector[float] v
  11.  
  12. def __cinit__(self, Py_ssize_t ncols):
  13. self.ncols = ncols
  14.  
  15. def add_row(self):
  16. """Adds a row, initially zero-filled."""
  17. self.v.resize(self.v.size() + self.ncols)
  18.  
  19. def __getbuffer__(self, Py_buffer *buffer, int flags):
  20. cdef Py_ssize_t itemsize = sizeof(self.v[0])
  21.  
  22. self.shape[0] = self.v.size() / self.ncols
  23. self.shape[1] = self.ncols
  24.  
  25. # Stride 1 is the distance, in bytes, between two items in a row;
  26. # this is the distance between two adjacent items in the vector.
  27. # Stride 0 is the distance between the first elements of adjacent rows.
  28. self.strides[1] = <Py_ssize_t>( <char *>&(self.v[1])
  29. - <char *>&(self.v[0]))
  30. self.strides[0] = self.ncols * self.strides[1]
  31.  
  32. buffer.buf = <char *>&(self.v[0])
  33. buffer.format = 'f' # float
  34. buffer.internal = NULL # see References
  35. buffer.itemsize = itemsize
  36. buffer.len = self.v.size() * itemsize # product(shape) * itemsize
  37. buffer.ndim = 2
  38. buffer.obj = self
  39. buffer.readonly = 0
  40. buffer.shape = self.shape
  41. buffer.strides = self.strides
  42. buffer.suboffsets = NULL # for pointer arrays only
  43.  
  44. def __releasebuffer__(self, Py_buffer *buffer):
  45. pass

The method Matrix.getbuffer fills a descriptor structure,called a Py_buffer, that is defined by the Python C-API.It contains a pointer to the actual buffer in memory,as well as metadata about the shape of the array and the strides(step sizes to get from one element or row to the next).Its shape and strides members are pointersthat must point to arrays of type and size Py_ssize_t[ndim].These arrays have to stay alive as long as any buffer views the data,so we store them on the Matrix object as members.

The code is not yet complete, but we can already compile itand test the basic functionality.

  1. >>> from matrix import Matrix
  2. >>> import numpy as np
  3. >>> m = Matrix(10)
  4. >>> np.asarray(m)
  5. array([], shape=(0, 10), dtype=float32)
  6. >>> m.add_row()
  7. >>> a = np.asarray(m)
  8. >>> a[:] = 1
  9. >>> m.add_row()
  10. >>> a = np.asarray(m)
  11. >>> a
  12. array([[ 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.],
  13. [ 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]], dtype=float32)

Now we can view the Matrix as a NumPy ndarray,and modify its contents using standard NumPy operations.

Memory safety and reference counting

The Matrix class as implemented so far is unsafe.The add_row operation can move the underlying buffer,which invalidates any NumPy (or other) view on the data.If you try to access values after an add_row call,you’ll get outdated values or a segfault.

This is where releasebuffer comes in.We can add a reference count to each matrix,and lock it for mutation whenever a view exists.

  1. # distutils: language = c++
  2.  
  3. from cpython cimport Py_buffer
  4. from libcpp.vector cimport vector
  5.  
  6. cdef class Matrix:
  7.  
  8. cdef int view_count
  9.  
  10. cdef Py_ssize_t ncols
  11. cdef vector[float] v
  12. # ...
  13.  
  14. def __cinit__(self, Py_ssize_t ncols):
  15. self.ncols = ncols
  16. self.view_count = 0
  17.  
  18. def add_row(self):
  19. if self.view_count > 0:
  20. raise ValueError("can't add row while being viewed")
  21. self.v.resize(self.v.size() + self.ncols)
  22.  
  23. def __getbuffer__(self, Py_buffer *buffer, int flags):
  24. # ... as before
  25.  
  26. self.view_count += 1
  27.  
  28. def __releasebuffer__(self, Py_buffer *buffer):
  29. self.view_count -= 1

Flags

We skipped some input validation in the code.The flags argument to getbuffer comes from np.asarray(and other clients) and is an OR of boolean flagsthat describe the kind of array that is requested.Strictly speaking, if the flags contain PyBUFND, PyBUFSIMPLE,or PyBUF_F_CONTIGUOUS, __getbuffer must raise a BufferError.These macros can be cimport’d from cpython.buffer.

(The matrix-in-vector structure actually conforms to PyBUFND,but that would prohibit _getbuffer from filling in the strides.A single-row matrix is F-contiguous, but a larger matrix is not.)

References

The buffer interface used here is set out inPEP 3118, Revising the buffer protocol.

A tutorial for using this API from C is on Jake Vanderplas’s blog,An Introduction to the Python Buffer Protocol.

Reference documentation is available forPython 3and Python 2.The Py2 documentation also describes an older buffer protocolthat is no longer in use;since Python 2.6, the PEP 3118 protocol has been implemented,and the older protocol is only relevant for legacy code.