2
1

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

Last updated at Posted at 2024-08-07

はじめに

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

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

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

原因追求

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

python の mmap オブジェクトは 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 は次のようになっています。

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 は次のようになっています。

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 は次のようになっています。

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 のソースコードは以下にあります。

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

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 のメモリロード命令を2回実行していることです。
1回目は src のアドレスから読んでいます。
2回目は srcend-4 のアドレスから読んでいます。ここで srcend = src+4 なので実質は src のアドレスから読んでいることになります。

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

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

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

結論

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

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

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

補足1

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

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

補足2

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1