← Назад в лабу

Ускорение в 500 раз, при котором ничего не считалось: шесть тихих сбоев в симуляции на WebGPU

webgpusimulationdebugging

Прогон закончился за 0,04 мс на шаг — примерно в 500 раз быстрее, чем симуляция такого размера должна была идти. Отчёт о здоровье был чистым: ни NaN, ни бесконечностей, наибольшая сила равна нулю, и у каждой частицы ноль соседей. Он был чистым, потому что ничего не посчиталось.

Это случилось в protocell-genesis — симуляции на WebGPU, которую я написал, чтобы проверить, может ли огрублённый «первичный бульон» сам собраться в замкнутую везикулу: двухслойную мембрану с водой внутри. В этой модели не может, и в репозитории лежат измеренные причины. В самом большом прогоне было 483 268 частиц. За время проекта шесть багов вели себя как тот, что выше: ни исключения, ни ошибки на странице, а числа выглядели правдоподобно или даже хорошо. Три из них специфичны для WebGPU. Вот они — как выглядел каждый и что изменилось.

1. Буфер больше, чем позволяет устройство

При размере бокса 85 σ списку соседей Верле требовалось 5 484 474 528 байт. maxStorageBufferBindingSize устройства был 4 294 967 292.

WebGPU не бросает исключение, когда createBuffer не проходит валидацию. Он пишет предупреждение в консоль и возвращает недействительный буфер, а все последующие dispatch на bind group, которая его использует, отбрасываются. Цикл симуляции продолжал шагать и продолжал читать нули: max|F| = 0, 0 неконечных значений, 0 соседей, 0,0405 мс на шаг.

Исправление — проверка до выделения памяти. createSoup считает каждый буфер, размер которого растёт с числом частиц, и сравнивает его с собственными лимитами устройства:

const bindingLimit = device.limits.maxStorageBufferBindingSize
const bufferLimit = device.limits.maxBufferSize
for (const [what, bytes] of candidates) {
  if (bytes > bindingLimit || bytes > bufferLimit) {
    throw new Error(
      `buffer ${what} needs ${bytes} bytes at N=${capacityN}; ` +
        `maxStorageBufferBindingSize=${bindingLimit}, ` +
        `maxBufferSize=${bufferLimit}`,
    )
  }
}

2. Чтение буфера без COPY_SRC

Чтобы прочитать данные с GPU, их копируют в отображаемый (mappable) промежуточный буфер. Если исходный буфер создан без GPUBufferUsage.COPY_SRC, копирование — ошибка валидации. Она делает недействительным командный буфер, и submit ничего не делает. Промежуточный буфер только что создан, WebGPU заполняет новые буферы нулями, и mapAsync завершается как обычно. Вы получаете нули, которые выглядят как данные.

Двум буферам случайных чисел не хватало этого флага. Все 72 чекпойнта на диске сохранили состояние генератора случайных чисел из одних нулей, поэтому каждый возобновлённый прогон засевал термостат значением 0 для каждой частицы. Когда у всех частиц одинаковый ланжевеновский шум, вся система дрейфует как одно тело: среднеквадратичное смещение было одинаковым, 62,32 σ², у всех семи видов частиц, а относительная диффузия, которая и позволяет агрегатам находить друг друга, исчезла. После исправления наибольший агрегат на шаге 90 000 вырос с 268 до 588.

usage — читаемое свойство GPUBuffer, так что весь этот класс багов стоит одной побитовой проверки:

if ((src.usage & GPUBufferUsage.COPY_SRC) === 0) {
  throw new Error(
    'readBack: the buffer was created without ' +
      `GPUBufferUsage.COPY_SRC (usage=${src.usage})`,
  )
}

3. requestDevice() без requiredLimits

Устройство, запрошенное без requiredLimits, получает лимиты по умолчанию, а не те, что поддерживает адаптер. По умолчанию разрешено 8 storage-буферов на стадию шейдера. Ядра связей переросли это число, создание пайплайна с layout 'auto' провалилось с предупреждением в консоли, и каждый затронутый проход ничего не делал. Связи не образовались вовсе.

Лимиты размера отказывают так же, только от числа частиц, а не от правки кода. maxStorageBufferBindingSize по умолчанию — 128 МиБ. При 1 000 слотов соседей на частицу список помещался при 28 400 частицах (113,6 МБ) и молча ничего не делал при 36 400 (145,6 МБ).

Теперь устройство запрашивает собственные максимумы адаптера:

const { limits } = adapter
const device = await adapter.requestDevice({
  requiredLimits: {
    maxStorageBuffersPerShaderStage:
      limits.maxStorageBuffersPerShaderStage,
    maxStorageBufferBindingSize: limits.maxStorageBufferBindingSize,
    maxBufferSize: limits.maxBufferSize,
  },
})

4. Защита от NaN, которая не могла сработать

Проверка безопасности бросала исключение, когда частицы смещались дальше половины оболочки Верле (skin), через sqrt(maxDriftSq) > skin / 2. Любое сравнение с NaN ложно. Как только координаты становились NaN, смещение тоже было NaN, и проверка проходила на каждом такте. Защита работала всё время и не могла увидеть сбой, ради которого её написали.

Исправление на том же такте, до старого сравнения, просматривает биты экспоненты IEEE-754 у каждой координаты. Оно стоит 0,212 мс на порцию из 1 000 шагов, которая занимает 331,68 мс, то есть 0,064 %. На составе, который молча расходился, прогон теперь бросает исключение на шаге 1 000 и называет 6 957 неконечных компонент из 60 750.

5. Силы до изменения размера бокса

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

Теперь тест закрепляет это с обеих сторон. С исправлением буфер сил на GPU совпадает со свежим расчётом с точностью до 1,5e-4 при масштабе сил 513. Без него буфер побитно равен старым силам и расходится на 313,7.

Это сдвинуло результат, который я уже описал. При боксе 30 наибольший агрегат оказался в 2,28 раза меньше, когда силы стали правильными (по три прогона с каждой стороны, диапазоны не пересекаются), а эффект, о котором я писал для циклов высушивания, сократился с 12,5–14,5× до 8,1–8,4×. Защита, срабатывавшая 9 раз из 216, после исправления сработала 0 раз из 36.

6. Проверки, узнававшие свой вход по имени

Этот баг к GPU отношения не имеет. Проверки перколяции, которые превращают замеры в вердикты «прошло» или «не прошло», находили свой вход по метке кампании zfB54, зашитой в код. Первая кампания с другой меткой без всякой ошибки перевела проверку в «не доказано». Теперь у строк есть поле role, а метка — лишь запасной вариант.

Конвейер, который узнаёт свои входы по имени, замолкает, когда кто-то запускает новый эксперимент, — то есть ровно тогда, когда его вердикт важен.

Седьмой: прибор читал нули

Тест, который проверял, рисует ли страница, читал canvas WebGPU через drawImage в 2D-canvas. Он сообщил о 0 изменённых пикселей из 30 000 на странице, которая только что нарисовала 119 кадров с 661 экземпляром. Теперь тест использует собственные счётчики экземпляров сцены и настоящий page.screenshot().

Что я теперь считаю сбоем

Нулевые силы, ноль соседей и ноль связей — это сбои, пока что-то не покажет, что физика может их дать. Так же и ускорение, которого никто не делал. То же с защитой, которая ни разу не срабатывала, пока я не увижу, как она срабатывает нарочно.

WebGPU сообщает об ошибках валидации в консоль и в событие uncapturederror устройства, а области ошибок (device.pushErrorScope('validation')) позволяют дождаться их вокруг блока работы. В этом проекте используются явные проверки, которые бросают исключение и называют буфер и число байт. Что бы вы ни выбрали, по умолчанию будет предупреждение в консоли и результат, который выглядит нормально.

Код, отчёт проверок и полный вердикт — в репозитории, а галерея снимков показывает настоящие чекпойнты в любом современном браузере.