はじめに
先日、何気に 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 で確保した領域にアクセスすると、同じアドレスに2回のアクセスが発生するのは何故ですか?」というものです。
この件に関して、個人的にちょっと思い当たることがあったので調べてみました。
前述のトピックにも答え(?)として投稿しましたが、こちらでも日本語で説明しておきます。
原因追求
python の mmap オブジェクトを追ってみる
python の mmap オブジェクトは 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 は次のようになっています。
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 は次のようになっています。
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 は次のようになっています。
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)ラベル以降のコードが実行されることが判ります。
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)フォーラムでも、同様の事例と思われるトピックをが見つけることができました。
- 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 に同様の例を投稿しています。
補足2
非キャッシュ領域へのアクセスは mmap オブジェクトの read/write メソッドではなく、numpy の配列を使うと良いでしょう。詳細は次の記事を参照してください。