Python の mmap.read メソッドを使って arm64 の非キャッシュ領域をアクセスするときの挙動について



はじめに
--------------------------------------------------

先日、何気に AMD(旧Xilinx)社のフォーラムをみていたら、次のような投稿が目に入りました。

* 「Why Are My UIO accesses from Python Being Done Twice in the Logic using PetaLinux/Vivado 2024.1」
   AMD Adaptive SoC & FPGA Support
   https://support.xilinx.com/s/question/0D54U00008Z19O5SAJ/why-are-my-uio-accesses-from-python-being-done-twice-in-the-logic-using-petalinuxvivado-20241

内容をかいつまんで訳すと、「PetaLinux/Vivado 2024.1 で、python の mmap オブジェクトの read メソッドを使って uio で確保した領域にアクセスすると、同じアドレスに２回のアクセスが発生するのは何故ですか？」というものです。

この件に関して、個人的にちょっと思い当たることがあったので調べてみました。
前述のトピックにも答え(？)として投稿しましたが、こちらでも日本語で説明しておきます。


原因追求
--------------------------------------------------

### python の mmap オブジェクトを追ってみる

python の mmap オブジェクトは C で記述されています。
ソースコードは以下にあります。

* https://github.com/python/cpython/blob/main/Modules/mmapmodule.c

このオブジェクトのメソッドテーブルは次のようになっています。

```C:https://github.com/python/cpython/blob/main/Modules/mmapmodule.c
static struct PyMethodDef mmap_object_methods[] = {
    {"close",           (PyCFunction) mmap_close_method,        METH_NOARGS},
    {"find",            (PyCFunction) mmap_find_method,         METH_VARARGS},
    {"rfind",           (PyCFunction) mmap_rfind_method,        METH_VARARGS},
    {"flush",           (PyCFunction) mmap_flush_method,        METH_VARARGS},
#ifdef HAVE_MADVISE
    {"madvise",         (PyCFunction) mmap_madvise_method,      METH_VARARGS},
#endif
    {"move",            (PyCFunction) mmap_move_method,         METH_VARARGS},
    {"read",            (PyCFunction) mmap_read_method,         METH_VARARGS},
    {"read_byte",       (PyCFunction) mmap_read_byte_method,    METH_NOARGS},
    {"readline",        (PyCFunction) mmap_read_line_method,    METH_NOARGS},
    {"resize",          (PyCFunction) mmap_resize_method,       METH_VARARGS},
    {"seek",            (PyCFunction) mmap_seek_method,         METH_VARARGS},
    {"seekable",        (PyCFunction) mmap_seekable_method,     METH_NOARGS},
    {"size",            (PyCFunction) mmap_size_method,         METH_NOARGS},
    {"tell",            (PyCFunction) mmap_tell_method,         METH_NOARGS},
    {"write",           (PyCFunction) mmap_write_method,        METH_VARARGS},
    {"write_byte",      (PyCFunction) mmap_write_byte_method,   METH_VARARGS},
    {"__enter__",       (PyCFunction) mmap__enter__method,      METH_NOARGS},
    {"__exit__",        (PyCFunction) mmap__exit__method,       METH_VARARGS},
#ifdef MS_WINDOWS
    {"__sizeof__",      (PyCFunction) mmap__sizeof__method,     METH_NOARGS},
#ifdef Py_DEBUG
    {"_protect",        (PyCFunction) mmap_protect_method,      METH_VARARGS},
#endif // Py_DEBUG
#endif // MS_WINDOWS
    {NULL,         NULL}       /* sentinel */
};
```

mmap の read メソッドは mmap_read_method です。
mmap_read_method は次のようになっています。

```C:https://github.com/python/cpython/blob/main/Modules/mmapmodule.c
static PyObject *
mmap_read_method(mmap_object *self,
                 PyObject *args)
{
    Py_ssize_t num_bytes = PY_SSIZE_T_MAX, remaining;

    CHECK_VALID(NULL);
    if (!PyArg_ParseTuple(args, "|O&:read", _Py_convert_optional_to_ssize_t, &num_bytes))
        return NULL;
    CHECK_VALID(NULL);

    /* silently 'adjust' out-of-range requests */
    remaining = (self->pos < self->size) ? self->size - self->pos : 0;
    if (num_bytes < 0 || num_bytes > remaining)
        num_bytes = remaining;

    PyObject *result = _safe_PyBytes_FromStringAndSize(self->data + self->pos,
                                                       num_bytes);
    if (result != NULL) {
        self->pos += num_bytes;
    }
    return result;
}
```

このメソッドの本体は _safe_PyBytes_FromStringAndSize です。
_safe_PyBytes_FromStringAndSize は次のようになっています。

```C:https://github.com/python/cpython/blob/main/Modules/mmapmodule.c
PyObject *
_safe_PyBytes_FromStringAndSize(char *start, size_t num_bytes) {
    if (num_bytes == 1) {
        char dest;
        if (safe_byte_copy(&dest, start) < 0) {
            return NULL;
        }
        else {
            return PyBytes_FromStringAndSize(&dest, 1);
        }
    }
    else {
        PyObject *result = PyBytes_FromStringAndSize(NULL, num_bytes);
        if (result == NULL) {
            return NULL;
        }
        if (safe_memcpy(PyBytes_AS_STRING(result), start, num_bytes) < 0) {
            Py_CLEAR(result);
        }
        return result;
    }
}
```

ここで num_bytes が 4 の場合は safe_memcpy が呼ばれます。
safe_memcpy は次のようになっています。

```C:https://github.com/python/cpython/blob/main/Modules/mmapmodule.c
int
safe_memcpy(void *dest, const void *src, size_t count)
{
    HANDLE_INVALID_MEM(
        memcpy(dest, src, count);
    );
    return 0;
}
```

つまり、mmap の read メソッドを使って 4 バイト読む場合は、memcpy を使ってソースからデータをコピーしています。
さて、ここの memcpy ですが、python が使う標準ライブラリにあります。
そして普通の Linux のシステムでは 標準ライブラリとして glibc が使われます。

### glibc の memcpy の詳細

そこで glibc の memcpy がどうなっているのか見てみます。
arch64 アーキテクチャの glibc の memcpy のソースコードは以下にあります。

* https://github.com/bminor/glibc/blob/master/sysdeps/aarch64/memcpy.S

これによると 4 バイトのコピーの場合は以下のL(copy8)ラベル以降のコードが実行されることが判ります。

```asm:https://github.com/bminor/glibc/blob/master/sysdeps/aarch64/memcpy.S
ENTRY (MEMCPY)
	PTR_ARG (0)
	PTR_ARG (1)
	SIZE_ARG (2)

	add	srcend, src, count
	add	dstend, dstin, count
	cmp	count, 128
	b.hi	L(copy_long)
	cmp	count, 32
	b.hi	L(copy32_128)

	/* Small copies: 0..32 bytes.  */
	cmp	count, 16
	b.lo	L(copy16)
	ldr	A_q, [src]
	ldr	B_q, [srcend, -16]
	str	A_q, [dstin]
	str	B_q, [dstend, -16]
	ret

	/* Copy 8-15 bytes.  */
L(copy16):
	tbz	count, 3, L(copy8)
	ldr	A_l, [src]
	ldr	A_h, [srcend, -8]
	str	A_l, [dstin]
	str	A_h, [dstend, -8]
	ret

	/* Copy 4-7 bytes.  */
L(copy8):
	tbz	count, 2, L(copy4)
	ldr	A_lw, [src]          /* attention! */
	ldr	B_lw, [srcend, -4]   /* attention! */
	str	A_lw, [dstin]
	str	B_lw, [dstend, -4]
	ret

	/* Copy 0..3 bytes using a branchless sequence.  */
L(copy4):
	cbz	count, L(copy0)
	lsr	tmp1, count, 1
	ldrb	A_lw, [src]          
	ldrb	C_lw, [srcend, -1]   
	ldrb	B_lw, [src, tmp1]
	strb	A_lw, [dstin]
	strb	B_lw, [dstin, tmp1]
	strb	C_lw, [dstend, -1]
L(copy0):
	ret
```

この L(copy8) のコードの注目ポイントは、arm64 のメモリロード命令を２回実行していることです。
１回目は src のアドレスから読んでいます。
２回目は srcend-4 のアドレスから読んでいます。ここで srcend = src+4 なので実質は src のアドレスから読んでいることになります。

これは、メモリがキャッシュ領域の場合は、１回目のメモリロード命令でメモリから読み出されたデータはデータキャッシュに取り込まれ、２回目のメモリロード命令はデータキャッシュから読まれるので、メモリからの読み出しは１回しか発生しません。

しかし、メモリが非キャッシュ領域の場合は、この２回のメモリロード命令により、メモリからの読み出しが２回発生します。

これが、python の mmap.read メソッドを使って非キャッシュ領域を４バイト読んだ時に、メモリからの読み出しが２回発生するしくみです。

結論
--------------------------------------------------

 * python の mmap.read は標準ライブラリの memcpy() を使っている
 * glibc が提供する memcpy() と memset() はキャッシュが有効であることを前提にした数々の最適化が行われている

これらの結果、python の mmap.read メソッドを使って非キャッシュ領域を４バイト読んだ時に、メモリからの読み出しが２回発生します。

arm64 で glibc の memcpy() や memset() を非キャッシュ領域で使う際には注意が必要ですね。


補足1
--------------------------------------------------

glibc が提供する memcpy() と memset() はキャッシュが有効であることを前提にした数々の最適化が行われているため、メモリが非キャッシュ領域の場合は思いもよらぬ問題を引き起すことがあります。
AMD(Xilinx)フォーラムでも、同様の事例と思われるトピックをが見つけることができました。

* https://support.xilinx.com/s/question/0D54U00008bDEPGSA4/zynqmp-memset-raises-unhandled-alignment-fault-error
* https://support.xilinx.com/s/question/0D54U00008Wd2w2SAB/buserror-when-accessing-plddr4-with-memcpy-ultrascale-linux-a53
* https://support.xilinx.com/s/question/0D52E00006hpPMFSA2/axi-bram-bus-error-in-linux-arm64

また、過去にも Qiita に同様の例を投稿しています。

* [『ARM64 で非キャッシュ領域に memset() を実行するとバスエラーになる件』@Qiita](https://qiita.com/ikwzm/items/3216f907ff8fc41866c4)


補足2
--------------------------------------------------

非キャッシュ領域へのアクセスは mmap オブジェクトの read/write メソッドではなく、numpy の配列を使うと良いでしょう。詳細は次の記事を参照してください。

* [『Python と Numpy で UIO を制御』@Qiita](https://qiita.com/ikwzm/items/d7559858d099c86c350c)


補足3 (2025年1月28日追記)
--------------------------------------------------

numpy を使う方法は手軽で良いのですが、筆者は numpy 本来の目的と異なる使い方をしているのではないかという疑念を持っています。このまま numpy に頼りつづけるのは少々不安なので、numpy を使わずに済むように、低レベルな I/O アクセスを行うための C で記述した Python モジュールを作りました。詳細は以下の記事を参照してください。

 *[『メモリマップド I/O レジスタにアクセスする Python モジュール を C で記述』@Qiita](https://qiita.com/ikwzm/items/c9d13045aac50e32ea35)
 *[『Python と mmapio モジュール で UIO を制御』@Qiita](https://qiita.com/ikwzm/items/01a3c7d58c340db57b2c)