2025, Sep 23 06:03

Тихая порча данных в массивах complex128: кейс NumPy/Numba и проверка памяти

Тихая порча данных в NumPy/Numba на массивах complex128: симптомы, проверка MemTest86 и почему для расчётов важна память с ECC и как это выявить на практике.

Тихая порча данных в крупных численных массивах — из тех ошибок, что похожи на редкий программный кейс, но на деле оказываются аппаратной пропастью. Если вы работаете с NumPy, Numba и массивами complex128 в симуляциях, требовательных к памяти, можете столкнуться с ситуациями, где результаты иногда оказываются неверными без падений, исключений или предупреждений. Ниже — разбор кейса: как проявлялась проблема и что в итоге её устранило.

Контекст: неожиданная порча в массивах complex128

Нагрузка выполняется на машине под Linux с 128 ГБ ОЗУ и сильно упирается в память: несколько массивов по ~26 ГБ. Тип данных — complex128. Выделение памяти проходит, присваивания срабатывают, потребление памяти выглядит корректно. Но при поиске экстремумов действительная часть ведёт себя нормально, а мнимая иногда возвращает абсурдно завышенные максимумы. Минимумы чаще выглядят правдоподобно, но неверны; максимумы нередко оказываются близкими к пределу двойной точности, изредка NaN, но никогда Inf. Перезапись подозрительного элемента не меняет его значения. Проблема воспроизводится чаще при высоком давлении на память и проявляется нерегулярно между запусками.

Воспроизведение: NumPy + Numba на больших массивах complex

Следующий пример демонстрирует поведение: он проходит по срезам и ищет экстремумы действительной и мнимой частей. Логика не менялась; для читаемости переименованы только идентификаторы.

import numpy as np
import numba


@numba.jit(cache=True)
def extrema_re_im(arr):
    r_hi = arr[0].real
    r_lo = arr[0].real
    i_hi = arr[0].imag
    i_lo = arr[0].imag

    for z in arr[1:]:
        if z.real > r_hi:
            r_hi = z.real
        elif z.real < r_lo:
            r_lo = z.real
        if z.imag > i_hi:
            i_hi = z.imag
        elif z.imag < i_lo:
            i_lo = z.imag
    return (r_lo, r_hi, i_lo, i_hi)


n_tau = 2048
side = 1024

mesh = np.empty((side, side), dtype=complex)
mesh.real[:] = np.random.rand(*mesh.shape)[:]
mesh.imag[:] = np.random.rand(*mesh.shape)[:]
weights = np.empty(n_tau, dtype=complex)
weights.real[:] = np.random.rand(*weights.shape)[:]
weights.imag[:] = np.random.rand(*weights.shape)[:]
plane = mesh[:, :] * weights[0]
(rmin, rmax, imin, imax) = extrema_re_im(plane.flatten())

cube = np.empty((n_tau, side, side), dtype=complex)
for t in range(n_tau):
    plane = mesh[:, :] * weights[t]
    (rmin2, rmax2, imin2, imax2) = extrema_re_im(plane.flatten())
    if rmin2 < rmin:
        rmin = rmin2
    elif rmax2 > rmax:
        rmax = rmax2
    if imin2 < imin:
        imin = imin2
    elif imax2 > imax:
        imax = imax2

    cube[t] = plane[:, :]

print((rmin, rmax, imin, imax))
print(extrema_re_im(cube.flatten()))

Есть и более короткий, но показательный фрагмент: он заполняет большой 3D‑массив и сразу считывает максимум мнимой части:

import numpy as np

n_tau = 2048
side = 1024

cube = np.empty((n_tau, side, side), dtype=complex)
cube.real[:] = np.random.rand(*cube.shape)[:]
cube.imag[:] = np.random.rand(*cube.shape)[:]

print(np.max(cube.imag))

Что на самом деле происходит

Симптомы указывают на недетерминированную порчу, возникающую при высоком давлении на память, затрагивающую отдельные биты в представлении чисел с плавающей точкой и не вызывающую исключений Python или сбоев ОС. Действительная часть остаётся согласованной, тогда как мнимая время от времени «взмывает» почти к максимальным значениям float64, порой с характерными битовыми шаблонами. Попытки перезаписать повреждённые значения не приживаются.

Причина оказалась не в NumPy, не в Numba и не в способе использования массивов. Запуск MemTest86 выявил устойчивые ошибки памяти — настолько многочисленные, что тест остановился после превышения 100000 ошибок. Сбои были одно-битовыми и повторялись, в том числе в первых двух байтах. Любая комбинация четырёх модулей памяти в четырёх слотах DIMM давала ошибки, что указывало на ненадёжность системы на аппаратном уровне при такой нагрузке. В машине — 128 ГБ не-ECC UDIMM на Ryzen 9 3900X. Поскольку ошибки проявлялись со всеми модулями и слотами, круг вероятных причин сузился до блока питания или контроллера памяти в ЦП — до дальнейших проверок с заменой компонентов.

Решение: сначала проверьте надёжность памяти, затем отлаживайте софт

Практический выход — подтвердить состояние системы профильным тестом памяти и считать его результаты определяющими. MemTest86 обнаружил масштабные одно-битовые ошибки. Это подтвердило аппаратную природу порчи и объяснило, почему сбой проявлялся нерегулярно, появлялся на больших объёмах и не сопровождался программными исключениями.

Важно, что ECC защищает от случайных битовых флиппов и умеет сообщать, когда исправляет ошибки или когда исправить их не удаётся, что сильно упрощает локализацию неисправностей. Это не превращает плохие ячейки памяти в хорошие, но позволяет обнаруживать и диагностировать проблемы вместо тихой порчи данных.

Поскольку проблема аппаратная, никакие правки кода её не устранят. Приведённые примеры корректны как есть; они лишь демонстрируют, как тихая порча памяти проявляется в больших массивах complex128.

Почему это важно для численных расчётов

Крупные численные симуляции нагружают все узлы системы — от таймингов DRAM до контроллера памяти в процессоре. Тихая порча легко маскируется под логическую ошибку в ядрах или редкий случай в математике. Когда массивы разрастаются до десятков гигабайт, а результаты иногда «плывут» без исключений, прежде чем переписывать код или менять алгоритмы, жизненно важно исключить аппаратные причины.

Выводы

Если экстремумы, нормы или другие редукции на больших массивах время от времени дают нелепые значения — особенно лишь по одной компоненте, например по мнимой, — подозревайте платформу. Проверьте машину инструментом вроде MemTest86. Если ошибки есть, не гоняйтесь за «призраками» в софте. Подумайте о мерах надёжности, подходящих вашей нагрузке: диагностике, поочерёдной замене компонентов для изоляции виновника (БП, контроллер памяти в ЦП, модули DIMM, плата) и использовании ECC, когда риск тихой порчи неприемлем.

Короче говоря, если массивы complex128 начинают выдавать невозможные максимумы и «не принимают» записи, не подгоняйте объяснение под программу. Сначала докажите исправность железа.

Статья основана на вопросе на StackOverflow от laserpropsims и ответе от laserpropsims.