вариант

cupynumeric-parallel-data-load

NVIDIA/skills NVIDIA/skills

Загрузка фрагментированных наборов данных (npy, Parquet, HDF5, необработанные двоичные данные) в распределенные массивы cuPyNumeric с использованием ручного разбиения на части и запуска задач с помощью Legate.

...Расширить все
0
Обновлено время 28 сентября 2026 г.

Параллельная загрузка данных с фрагментацией -> cupynumeric

Зачем нужен этот навык. cupynumeric дублирует API массивов NumPy, включая cupynumeric.load для одного файла .npy. Помимо этого, загрузка файлов осуществляется в Legate, а не в cupynumeric:

Формат Встроенный загрузчик
Один файл .npy cupynumeric.load(path) (соответствие API NumPy)
HDF5 (один файл) legate.io.hdf5.from_file / from_file_batched
Распределённые множественные файлы (любой формат), Parquet/Arrow, необработанные двоичные данные, пользовательские макеты Встроенного загрузчика нет — для этого используется данный навык.

Этот навык демонстрирует канонический способ восполнить пробел в последней строке: напишите задачу Legate на Python, которая вызывает сторонний считыватель, необходимый для данного формата (h5py, pyarrow, np.memmap и т. д.), внутри тела задачи, и позвольте Legate распределить чтение между GPU / узлами. Для форматов со встроенным загрузчиком отдавайте ему предпочтение, если вам не требуется пользовательский код внутри тела задачи (загрузчик на основе mmap, формат-специфический декодер, метаданные sidecar, частичное / фрагментированное чтение).

Стандартный шаблон: ручное разделение + ручной запуск задачи, размер которой подбирается исходя из возможностей машины, а не файлов. Разбивается только ось 0; последующие оси перемещаются внутри каждого фрагмента. Количество строк в каждом фрагменте может различаться между файлами (соответствовать должны только dtype и последующие оси); при запуске заполняются все доступные процессоры, независимо от количества файлов.

Файл .npy служит примером, поскольку его заголовок содержит информацию о форме и типе данных на диске, но эта структура применима к любому формату с недорогим чтением диапазонов/фрагментов (необработанные двоичные данные, HDF5, Parquet/Arrow — см. раздел «Другие форматы» ниже). Эталонная реализация: assets/examples/parallel_npy_load.py.

Предположение о расположении данных

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

В примере рассматривается одна конкретная организация данных:

  • каталог, содержащий файлы с именами shard_0000.npy, shard_0001.npy, ... в виде непрерывной целочисленной последовательности (ширина 4 с нулевым заполнением).
  • Все фрагменты имеют одинаковый тип данных (dtype) и одинаковые конечные оси (shape[1:]); ось 0 (количество строк на фрагмент) может различаться между файлами — рецепт строит кумулятивную таблицу смещений строк и считывает перекрывающийся фрагмент каждого файла изнутри листьевой задачи.
  • Каталог доступен для каждого ранга (общая файловая система для многоузловых запусков).

Функция discover_layout() в примере выводит найденные данные и завершает работу с ошибкой с подробным описанием, если расположение данных неверно (отсутствует каталог, нет фрагментов, несовпадение dtype / конечных осей или пробел в непрерывной последовательности shard_NNNN.npy ).

Если ваши данные имеют иную структуру — необработанные двоичные данные с фиксированным шагом, файл HDF5 с одним набором данных на каждый фрагмент, дерево каталогов и т. д. — изменятся только шаблон glob, считыватель для каждого файла (шаг 4 ниже) и обнаружение метаданных (шаг 1 ниже). Механизм разбиения на части и запуска не зависит от структуры данных.

Когда использовать

См. таблицу форматов выше для принятия решения о маршрутизации (встроенный загрузчик или данный навык). Помимо этого, есть ещё два дополнительных признака того, что данный навык подходит:

  • Замена последовательного np.concatenate([read(f) for f in files]) на параллельное чтение с отдельным доступом к каждому GPU.
  • Демонстрация того, как пользовательская задача Legate на Python записывает данные в массив вывода cupynumeric посредством ручного запуска.

Примеры

Приведённые ниже пути указаны относительно каталога данного навыка (скрипт находится в assets/examples/parallel_npy_load.py). Измените префикс в соответствии с местом установки вашего навыка (например, skills/cupynumeric-parallel-data-load/assets/... если навык находится в каталоге верхнего уровня skills/ ).

# Один узел, 4 графических процессора.
legate --gpus 4 --fbmem 4000 --min-gpu-chunk 1 \
    assets/examples/parallel_npy_load.py \
    read --shard-dir /shared/scratch/demo
# Многоузловая конфигурация, 2 узла по 4 графических процессора (slurm), общая файловая система в --shard-dir.
# Сгенерируйте фрагменты один раз на ранке 0, затем запустите `read` в любом масштабе.
legate --launcher srun --nodes 2 --cpus 1 \
    assets/examples/parallel_npy_load.py \
    write --shard-dir /shared/scratch/demo

legate --launcher srun --nodes 2 --ranks-per-node 4 \
    --gpus 4 --fbmem 4000 --min-gpu-chunk 1 \
    assets/examples/parallel_npy_load.py \
    read --shard-dir /shared/scratch/demo

Без флагов макета — драйвер чтения просматривает каждый заголовок .npy для восстановления количества строк в каждом файле, конечной формы и типа данных, а затем вычисляет tile_rows на основе доступного количества процессоров.

Флаг --min-gpu-chunk 1 необходим только в том случае, если количество элементов в каждой плитке меньше минимального размера блока по умолчанию Legate для запусков на GPU (например, по умолчанию в примере — общее количество строк распределяется между 4 GPU по ~1 млн на каждый тайл — опускаются ниже порогового значения и в противном случае были бы объединены на одном GPU). Для наборов данных производственного масштаба (десятки миллионов элементов на тайл или больше) можно отказаться от этого флага и позволить Legate использовать значение по умолчанию. Увеличение этого значения до умеренного (например, --min-gpu-chunk 1024) целесообразно, когда каждый фрагмент достаточно велик, чтобы накладные расходы на каждую задачу имели большее значение, чем распределение фрагментов по каждому GPU.

Инструкции

Пять шагов из рабочего примера в формате .npy; только шаг 1 (разбор заголовка формата) и шаг 4 (считыватель по файлам внутри тела задачи) зависят от конкретного формата. Остальные три (выделение места назначения, разбиение на фрагменты, барьер) повторно используются без изменений во всех форматах — см. раздел «Другие форматы» ниже для точек переключения.

1. Считывание метаданных из каждого фрагмента

Просканируйте каталог и просмотрите каждый заголовок .npy (mmap_mode="r" считывает только заголовок). Заголовок содержит форму и тип данных для каждого фрагмента, поэтому драйвер может восстановить общее количество строк, форму конечных данных и таблицу совокупных смещений строк, даже не загружая данные:

paths = sorted(SHARD_DIR.glob("shard_*.npy"))

per_file_rows = []                       # количество строк по оси 0 в каждом файле
trailing_shape = None                    # shape[1:], должно совпадать во всех файлах
dtype = None
for p in paths:
    hdr = np.load(p, mmap_mode="r")
    if trailing_shape is None:
        trailing_shape = tuple(hdr.shape[1:])
        dtype = hdr.dtype
    elif tuple(hdr.shape[1:]) != trailing_shape or hdr.dtype != dtype:
        raise RuntimeError(
            f"{p.name}: несовпадение формы / типа данных"
            f"({hdr.shape[1:]}/{hdr.dtype} против {trailing_shape}/{dtype})"
        )
    per_file_rows.append(int(hdr.shape[0]))

cum_rows = np.cumsum([0] + per_file_rows, dtype=np.int64)  # длина N+1
total_rows = int(cum_rows[-1])

Приведённый выше фрагмент кода обеспечивает соответствие dtype и trailing_shape (т. е. shape[1:]) между файлами. Количество строк в каждом шарде может различаться — с этим справляется таблица cum-rows. В рабочем коде также следует проверить, что имена образуют непрерывную последовательность shard_0000.npy … shard_NNNN.npy (опущено в фрагменте для краткости; см. discover_layout() в примере с решением). Обнаружение опирается исключительно на то, что предоставляет сам формат на диске (в данном случае заголовок .npy, .shape / .dtype для HDF5 и т. д.); любые дополнительные элементы (манифест, хэши содержимого) являются отдельным этапом проверки сверху.

2. Создание выходного хранилища cupynumeric на основе метаданных

Общий массив охватывает total_rows по оси 0; последующие оси берутся из trailing_shape без изменений. Используйте cn.empty — задача перезаписывает каждую ячейку, инициализация нулями была бы напрасной.

import cupynumeric as cn

total_shape = (total_rows,) + trailing_shape
out = cn.empty(total_shape, dtype=dtype)

3. Разбиение массива на фрагменты по количеству процессоров

Размер формы запуска определяется количеством доступных процессоров, а не количеством файлов. Выберите tile_rows = ceil(total_rows / num_processors) и разделите ось 0 по этому размеру плитки. Остальные оси не разделяются (плитка охватывает там всю область). Последний фрагмент может быть неполным — именно это и поддерживает partition_by_tiling — поэтому рецепт не требует ограничения на делимость.

from legate.core import TaskTarget, get_legate_runtime
from legate.core.data_interface import as_logical_array

runtime = get_legate_runtime()
machine = runtime.get_machine()
num_processors = max(
    machine.count(TaskTarget.GPU),
    machine.count(TaskTarget.OMP),
    machine.count(TaskTarget.CPU),
    1,
)

tile_rows = max(1, (total_rows + num_processors - 1) // num_processors)
tile_shape = (tile_rows,) + trailing_shape
partition = as_logical_array(out).data.partition_by_tiling(tile_shape)

num_tasks = (total_rows + tile_rows - 1) // tile_rows  # сопоставить количество тайлов с количеством задач

4. Определите конечную задачу и запустите её вручную

PATHS и CUM_ROWS (пути к файлам и таблица кумулятивных смещений строк из шага 1) плюс TILE_ROWS заполняются драйвером в качестве глобальных переменных модуля перед запуском; управление репликацией запускает драйвер на каждом ранке, поэтому каждый рабочий узел видит одинаковые значения.

Каждая задача сначала создаёт своё представление-потребитель (cupy на GPU, numpy на CPU/OMP) и считывает фактическое количество строк тайла из view.shape[0] — у самого PhysicalStore нет атрибута .shape, поэтому необходимо пройти через представление. Затем она вычисляет свой глобальный диапазон строк на основе координаты запуска и этого количества строк, делит cum_rows пополам для перекрывающихся файлов и копирует каждый перекрывающийся фрагмент файла в соответствующий фрагмент назначения. Зарегистрируйте варианты для CPU, OMP и GPU, чтобы один и тот же запуск выполнялся без изменений в любом месте; диспетчеризация по ctx.get_variant_kind() выбирает потребителя, соответствующего тому, где находитсяOutputStore (cp.from_dlpack(dst) для FBMEM, np.asarray(dst) для SYSMEM). cupy импортируется только внутри ветки GPU, поэтому тело задачи загружается на машинах без cupy.

import bisect
from legate.core import TaskContext, VariantCode
from legate.core.task import OutputStore, task

@task(variants=(VariantCode.CPU, VariantCode.OMP, VariantCode.GPU))
def load_tile(ctx: TaskContext, dst: OutputStore) -> None:
    t = ctx.task_index[0]                              # индекс тайла 0..num_tasks-1

    variant = ctx.get_variant_kind()
    if variant == VariantCode.GPU:
        import cupy as cp                              # отложенная загрузка: только на GPU
        view = cp.from_dlpack(dst)
    else:
        view = np.asarray(dst)                         # numpy-представление с нулевым копированием

    tile_rows_actual = view.shape[0]                   # не хватает в последнем тайле
    row_start = t * TILE_ROWS                          # начало глобальной оси 0
    row_end = row_start + tile_rows_actual

    # Найти полуоткрытый диапазон индексов файлов, перекрывающийся с [row_start, row_end).
    first_file = bisect.bisect_right(CUM_ROWS, row_start) - 1
    last_file = bisect.bisect_right(CUM_ROWS, row_end - 1) - 1

    for f in range(first_file, last_file + 1):
        # Пересечение плитки [row_start, row_end) с файлом [cum[f], cum[f+1]).
        lo = max(row_start, int(CUM_ROWS[f]))
        hi = min(row_end, int(CUM_ROWS[f + 1]))
        file_lo = lo - int(CUM_ROWS[f])
        file_hi = hi - int(CUM_ROWS[f])
        dst_lo = lo - row_start
        dst_hi = hi - row_start
        chunk = np.ascontiguousarray(
            np.load(PATHS[f], mmap_mode="r")[file_lo:file_hi]
        )
        if variant == VariantCode.GPU:
            view[dst_lo:dst_hi].set(chunk)             # cudaMemcpyAsync H2D
        else:
            view[dst_lo:dst_hi] = chunk                # запись в numpy без копирования

manual_task = runtime.create_manual_task(
    load_tile.library,
    load_tile.task_id,
    (num_tasks,),                                      # количество задач в домене запуска == количество тайлов
)
manual_task.add_output(partition)
manual_task.execute()

Оба потребителя проходят через нативные производители PhysicalStore (__dlpack__ для cupy, __array_interface__ для np.asarray) — просмотры локального тайла с нулевым копированием. Сложность бисекции составляет O(log num_shards), а внутренний цикл обычно выполняется 1–2 раза (тайлы перекрываются не более чем на пару файлов).

5. Блокировка и проверка

get_legate_runtime().issue_execution_fence(block=True)

Жёсткие ограничения

  1. Все фрагменты должны иметь одинаковый тип данных (dtype) и конечные оси (shape[1:]). Рецепт складывает фрагменты по оси 0; конечные оси места назначения берутся из trailing_shape, которое на этапе обнаружения фиксируется на значении первого файла. Количество строк в каждом фрагменте (shape[0]) может свободно различаться — с этим справляется таблица кумулятивных смещений. В примере любой фрагмент, тип данных или конечная форма которого отличаются от первого, отбрасывается с подробным сообщением об ошибке.

  2. Выберите потребитель, соответствующий варианту. cp.from_dlpack отклоняет хранилища, расположенные в SYSMEM; np.asarray без предупреждения возвращает хост-представление хранилища, расположенного в FBMEM, в которое на самом деле нельзя записать данные. Выполняйте диспетчеризацию по ctx.get_variant_kind(), чтобы каждый вариант использовал свой собственный потребитель — см. шаг 4.

  3. Представления mmap не всегда являются C-сплошными — оберните каждый фрагмент по файлу с помощью np.ascontiguousarray(arr[file_lo:file_hi]) перед вызовом.set() или записью на месте в numpy.

  4. Многоузловая среда: SHARD_DIR должен находиться в общей файловой системе. Каждый рабочий процесс (на каждом ранге) открывает фрагменты по пути; локальные для узла пути /tmp работают только для одноузловых демонстраций.

Варианты

Быстрый путь с однородными фрагментами (одна задача на файл)

Когда все фрагменты уже имеют одинаковые (form, dtype), и у вас имеется количество процессоров, равное num_shards, механизм cum-rows / bisect создаёт лишнюю нагрузку. Установите tile_rows = shard_shape[0] и num_tasks = num_shards; тогда в разбиении будет по одному тайлу на файл, и каждая задача считывает ровно один файл от начала до конца (без бисекции, без внутреннего цикла). Переключение на стороне драйвера выполняется одной строкой:

if all(r == per_file_rows[0] for r in per_file_rows) and num_shards == num_processors:
    tile_rows = per_file_rows[0]
else:
    tile_rows = max(1, (total_rows + num_processors - 1) // num_processors)

Одно и то же тело задачи load_tile работает в обоих режимах — внутренний цикл просто выполняется ровно один раз на каждую задачу. Нет необходимости в отдельном теле задачи для быстрого пути.

Чрезмерная декомпозиция для лучшей балансировки нагрузки

Значение по умолчанию tile_rows = ceil(total_rows / num_processors) дает по одной плитке на каждый процессор. Чтобы провести избыточную декомпозицию с коэффициентом K (меньшие плитки, большее количество точечных задач, более мелкозернистая организация очередей), вместо этого нужно разделить на K * num_processors :

tile_rows = max(1, (total_rows + K * num_processors - 1) // (K * num_processors))

num_tasks = ceil(total_rows / tile_rows), что в итоге дает примерно K * num_processors. Те же самые задачи по-прежнему работают — алгоритм бисекции просто распределяет больше задач на каждый файл.

Другие форматы

Изменяется только считыватель на уровне файла внутри load_tile. Описание считывателя: при заданном пути к файлу и полуоткрытом диапазоне строк [file_lo, file_hi) по оси 0 возвращает массив numpy формы (file_hi - file_lo,) + trailing_shape, который можно сделать C-сплошным. Требуются недорогие операции чтения диапазонов/фрагментов — форматы, поддерживающие только «чтение всего файла», не подходят для случая частичного перекрытия (плитка, покрывающая только часть одного файла).

Формат Reader внутри листьевой задачи
.npy (рабочий пример) host = np.ascontiguousarray(np.load(p, mmap_mode="r")[file_lo:file_hi])
Необработанные двоичные данные (с фиксированной формой) arr = np.memmap(p, dtype=DTYPE, mode="r", shape=(rows_in_file, *trailing_shape)); host = np.ascontiguousarray(arr[file_lo:file_hi])
HDF5 с помощью h5py.File(p, "r") as f: host = np.ascontiguousarray(f["data"][file_lo:file_hi])
Parquet / Arrow tbl = pq.read_table(p, columns=..., use_threads=False).slice(file_lo, file_hi - file_lo); host = tbl.to_pandas().values

(Встроенные загрузчики с одним вызовом для каждого формата см. в таблице «Зачем нужен этот навык» в начале этого файла.)

На этапе обнаружения (шаг 1) анализируются метаданные каждого формата: .npy / HDF5 / Parquet — все они содержат на диске количество строк в каждом файле и тип данных (dtype). Необработанные двоичные данные — нет; эти сведения извлекаются из размера файла или содержатся в сопутствующем файле.

Распространённые ошибки

cn.asarray(dst) недопустимо в конечной задаче

Внутри тела @task любая операция cupynumeric, затрагивающая верхний уровень среды выполнения — cn.asarray(store), присваивание среза cn_dst[s] = host_np — запускает create_index_space из неправильного контекста, и Legion прерывает работу:

ИСКЛЮЧЕНИЕ ПРИ ИСПОЛЬЗОВАНИИ API LEGION: Недопустимый контекст задачи, переданный вызову среды выполнения
create_index_space

Исправление: используйте капсулу DLPack с помощью сторонней библиотеки (cupy / torch / numpy) внутри листьевых задач. Использование cn.asarray допустимо в драйвере, но не в листьевых задачах. См. файл examples/dlpack/leaf_task_interop.py для обходного решения с использованием torch.

Утверждение (assert) внутри задачи приводит к прерыванию работы среды выполнения

Legate рассматривает негенерированные исключения в @task как нарушение контракта и прерывает выполнение, если задача не была зарегистрирована с помощью throws_exception(). Перед запуском следует выполнить проверку корректности на хосте.

Домен запуска должен соответствовать количеству плиток разбиения

create_manual_task(launch_shape=...) и partition_by_tiling(...) являются независимыми — среда выполнения не обнаруживает несоответствие. Более крупная область запуска → плитки за пределами диапазона; более мелкая → незаписанные плитки. Всегда вычисляйте обе величины из одних и тех же (total_rows, tile_rows) с помощью двух отдельных делений с округлением в большую сторону (если определять размер области запуска напрямую по num_processors, то при num_processors > total_rows произойдёт избыточный запуск):

tile_rows = max(1, (total_rows + num_processors - 1) // num_processors)
num_tasks = (total_rows + tile_rows - 1) // tile_rows
partition = ...partition_by_tiling((tile_rows,) + trailing_shape)
runtime.create_manual_task(load_tile.library, load_tile.task_id, (num_tasks,))
Посмотреть на GitHub
---
name: cupynumeric-parallel-data-load
description: Load sharded datasets (npy, Parquet, HDF5, raw binary) into distributed cuPyNumeric arrays using manual partitioning and Legate task launches.
license: CC-BY-4.0 OR Apache-2.0
---

# Parallel sharded data -> cupynumeric load

**Why this skill exists.** cupynumeric mirrors NumPy's array API,
including `cupynumeric.load` for a single `.npy` file. Beyond that,
file *loading* lives in Legate, not cupynumeric:

| Format | Built-in loader |
|---|---|
| Single `.npy` | `cupynumeric.load(path)` (NumPy-API parity) |
| HDF5 (single file) | `legate.io.hdf5.from_file` / `from_file_batched` |
| Sharded multi-file (any format), Parquet/Arrow, raw binary, custom layouts | **No built-in loader — this skill.** |

This skill shows the canonical way to fill the gap in the last row:
write a Legate Python task that calls the third-party reader the
format needs (`h5py`, `pyarrow`, `np.memmap`, ...) inside the
task body, and let Legate distribute the reads across GPUs / nodes.
For the formats with a built-in loader, prefer it unless you need a
custom in-task body (mmap-based loader, format-specific decoder,
sidecar metadata, partial / sharded reads).

Canonical pattern: **manual partition + manual task launch, sized to
the machine, not the files.** Only axis 0 is sharded; trailing axes
ride along inside each tile. Per-shard row counts may differ across
files (only `dtype` and trailing axes must match); the launch fills
every available processor regardless of how many files there are.

`.npy` is the worked example because the header carries shape and
dtype on disk, but the skeleton applies to any format with cheap
range/slice reads (raw binary, HDF5, Parquet/Arrow — see "Other
formats" below). Reference implementation:
[`assets/examples/parallel_npy_load.py`](assets/examples/parallel_npy_load.py).

## Data layout assumption

This skill is purely about **loading** — it assumes the data is already
laid out on a shared filesystem in some predictable, indexable way.
Producing those files is out of scope (the example ships a `write`
subcommand for convenience, but real users bring their own).

The worked example assumes one specific layout:

- A directory containing files named `shard_0000.npy`, `shard_0001.npy`,
  ... in a contiguous integer sequence (zero-padded width 4).
- All shards share the same `dtype` and the same trailing axes
  (`shape[1:]`); **axis 0 (rows per shard) may differ across files** —
  the recipe builds a cumulative row-offset table and reads each
  file's overlapping slice from inside the leaf task.
- The directory is visible to every rank (shared filesystem for
  multi-node runs).

The example's `discover_layout()` prints what it found and hard-fails
with a descriptive error when the layout is wrong (missing directory,
no shards, mismatched `dtype` / trailing axes, or a hole in the
contiguous `shard_NNNN.npy` sequence).

If your data lives in a different layout — fixed-stride raw binary, an
HDF5 file with one dataset per shard, a directory tree, ... — only the
glob pattern, the per-file reader (step 4 below), and the metadata
discovery (step 1 below) change. The partitioning and launch machinery
is layout-agnostic.

## When to use

See the format table above for the routing decision (built-in loader
vs. this skill). Beyond that, two additional cues that this skill is
the right fit:

- Replacing sequential `np.concatenate([read(f) for f in files])` with
  parallel per-GPU reads.
- Demonstrating how a user-defined Legate Python task writes into a
  cupynumeric output array via a manual launch.

## Examples

Paths below are written relative to this skill's directory (the script
ships at `assets/examples/parallel_npy_load.py`). Adjust the prefix to
match wherever your skill is installed (e.g.
`skills/cupynumeric-parallel-data-load/assets/...` if the skill lives
under a top-level `skills/` directory).

```bash
# Single-node, 4 GPUs.
legate --gpus 4 --fbmem 4000 --min-gpu-chunk 1 \
    assets/examples/parallel_npy_load.py \
    read --shard-dir /shared/scratch/demo
```

```bash
# Multi-node, 2 nodes x 4 GPUs (slurm), shared filesystem at --shard-dir.
# Generate the shards once on rank 0, then re-run `read` at any scale.
legate --launcher srun --nodes 2 --cpus 1 \
    assets/examples/parallel_npy_load.py \
    write --shard-dir /shared/scratch/demo

legate --launcher srun --nodes 2 --ranks-per-node 4 \
    --gpus 4 --fbmem 4000 --min-gpu-chunk 1 \
    assets/examples/parallel_npy_load.py \
    read --shard-dir /shared/scratch/demo
```

No layout flags — the read driver walks every `.npy` header to recover
per-file row counts, the trailing shape, and the dtype, then derives
`tile_rows` from the available processor count.

`--min-gpu-chunk 1` is only needed when the per-tile element count is
below Legate's default minimum chunk size for GPU launches (e.g. the
worked example's defaults — total rows split across 4 GPUs at
`~1M` per tile — fall below the threshold and would otherwise be
folded onto a single GPU). For production-sized datasets (tens of
millions of elements per tile or larger) you can drop the flag and
let Legate use its default. Bumping it to a moderate value (e.g.
`--min-gpu-chunk 1024`) is fine when each tile is large enough that
per-task overhead matters more than getting *every* GPU a tile.

## Instructions

Five steps from a `.npy` worked example; only step 1 (parsing the
format header) and step 4 (the per-file reader inside the task body)
are format-specific. The other three (allocate destination, partition,
fence) are reused unchanged across formats — see "Other formats" below
for the swap-points.

### 1. Read the metadata from every shard

Scan the directory and peek at every `.npy` header (`mmap_mode="r"`
reads only the header). The header carries the per-shard shape and
dtype, so the driver can recover total rows, trailing shape, and a
cumulative row-offset table without ever loading the data:

```python
paths = sorted(SHARD_DIR.glob("shard_*.npy"))

per_file_rows = []                       # rows along axis 0 per file
trailing_shape = None                    # shape[1:], must match across files
dtype = None
for p in paths:
    hdr = np.load(p, mmap_mode="r")
    if trailing_shape is None:
        trailing_shape = tuple(hdr.shape[1:])
        dtype = hdr.dtype
    elif tuple(hdr.shape[1:]) != trailing_shape or hdr.dtype != dtype:
        raise RuntimeError(
            f"{p.name}: trailing shape / dtype mismatch "
            f"({hdr.shape[1:]}/{hdr.dtype} vs {trailing_shape}/{dtype})"
        )
    per_file_rows.append(int(hdr.shape[0]))

cum_rows = np.cumsum([0] + per_file_rows, dtype=np.int64)  # length N+1
total_rows = int(cum_rows[-1])
```

The snippet above enforces matching `dtype` and `trailing_shape` (i.e.
`shape[1:]`) across files. **Per-shard row counts may differ** — the
cum-rows table handles that. Production code should also verify that
names form a contiguous `shard_0000.npy ... shard_NNNN.npy` sequence
(omitted from the snippet for brevity; see `discover_layout()` in the
worked example). Discovery relies only on what the
on-disk format itself exposes (the `.npy` header here, `.shape` /
`.dtype` for HDF5, etc.); any sidecar (manifest, content hashes) is a
separate verification step on top.

### 2. Create the cupynumeric output store from the metadata

The total array spans `total_rows` along axis 0; trailing axes come
from `trailing_shape` unchanged. Use `cn.empty` — the task overwrites
every cell, zero-init would be wasted.

```python
import cupynumeric as cn

total_shape = (total_rows,) + trailing_shape
out = cn.empty(total_shape, dtype=dtype)
```

### 3. Tile the store by processor count

The launch shape is sized to the **available processors**, not to the
file count. Pick `tile_rows = ceil(total_rows / num_processors)` and
partition axis 0 by that tile size. Trailing axes are not partitioned
(tile spans the full extent there). The last tile is allowed to be
short — that's exactly what `partition_by_tiling` supports — so the
recipe needs no divisibility constraint.

```python
from legate.core import TaskTarget, get_legate_runtime
from legate.core.data_interface import as_logical_array

runtime = get_legate_runtime()
machine = runtime.get_machine()
num_processors = max(
    machine.count(TaskTarget.GPU),
    machine.count(TaskTarget.OMP),
    machine.count(TaskTarget.CPU),
    1,
)

tile_rows = max(1, (total_rows + num_processors - 1) // num_processors)
tile_shape = (tile_rows,) + trailing_shape
partition = as_logical_array(out).data.partition_by_tiling(tile_shape)

num_tasks = (total_rows + tile_rows - 1) // tile_rows  # match partition tile count
```

### 4. Define the leaf task and launch it manually

`PATHS` and `CUM_ROWS` (the file paths and cumulative row-offset
table from step 1) plus `TILE_ROWS` are populated as module globals
by the driver before launching; control replication runs the driver
on every rank, so every worker sees identical values.

Each task builds its consumer view first (cupy on GPU, numpy on
CPU/OMP) and reads the tile's actual row count from `view.shape[0]`
— `PhysicalStore` itself has no `.shape` attribute, so going through
the view is required. It then computes its global row range from its
launch coordinate and that row count, bisects `cum_rows` for the
overlapping file(s), and copies each overlapping file slice into the
matching destination slice. Register CPU, OMP, and GPU variants so
the same launch runs unchanged anywhere; dispatch on
`ctx.get_variant_kind()` picks the consumer matching where the
`OutputStore` is resident (`cp.from_dlpack(dst)` for FBMEM,
`np.asarray(dst)` for SYSMEM). cupy is imported inside the GPU
branch only, so the task body loads on machines without cupy.

```python
import bisect
from legate.core import TaskContext, VariantCode
from legate.core.task import OutputStore, task

@task(variants=(VariantCode.CPU, VariantCode.OMP, VariantCode.GPU))
def load_tile(ctx: TaskContext, dst: OutputStore) -> None:
    t = ctx.task_index[0]                              # tile index 0..num_tasks-1

    variant = ctx.get_variant_kind()
    if variant == VariantCode.GPU:
        import cupy as cp                              # lazy: only on GPU
        view = cp.from_dlpack(dst)
    else:
        view = np.asarray(dst)                         # zero-copy numpy view

    tile_rows_actual = view.shape[0]                   # short on the last tile
    row_start = t * TILE_ROWS                          # global axis-0 start
    row_end = row_start + tile_rows_actual

    # Find the half-open range of file indices that overlap [row_start, row_end).
    first_file = bisect.bisect_right(CUM_ROWS, row_start) - 1
    last_file = bisect.bisect_right(CUM_ROWS, row_end - 1) - 1

    for f in range(first_file, last_file + 1):
        # Intersection of tile [row_start, row_end) with file [cum[f], cum[f+1]).
        lo = max(row_start, int(CUM_ROWS[f]))
        hi = min(row_end, int(CUM_ROWS[f + 1]))
        file_lo = lo - int(CUM_ROWS[f])
        file_hi = hi - int(CUM_ROWS[f])
        dst_lo = lo - row_start
        dst_hi = hi - row_start
        chunk = np.ascontiguousarray(
            np.load(PATHS[f], mmap_mode="r")[file_lo:file_hi]
        )
        if variant == VariantCode.GPU:
            view[dst_lo:dst_hi].set(chunk)             # cudaMemcpyAsync H2D
        else:
            view[dst_lo:dst_hi] = chunk                # zero-copy numpy write

manual_task = runtime.create_manual_task(
    load_tile.library,
    load_tile.task_id,
    (num_tasks,),                                      # launch domain == tile count
)
manual_task.add_output(partition)
manual_task.execute()
```

Both consumers go through `PhysicalStore`'s native producers
(`__dlpack__` for cupy, `__array_interface__` for `np.asarray`) —
zero-copy views of the local tile. Bisect cost is `O(log num_shards)`
and the inner loop typically iterates 1–2 times (tiles overlap at
most a couple of files).

### 5. Fence and verify

```python
get_legate_runtime().issue_execution_fence(block=True)
```

## Hard constraints

1. **All shards must share `dtype` and trailing axes (`shape[1:]`).**
   The recipe stacks shards along axis 0; the destination's trailing
   axes come from `trailing_shape`, which the discovery step locks to
   the value of the first file. Per-shard row counts (`shape[0]`) may
   freely differ — the cumulative-offset table handles them. The
   example rejects any shard whose `dtype` or trailing shape differs
   from the first one with a descriptive error.

2. **Pick the consumer that matches the variant.** `cp.from_dlpack`
   rejects SYSMEM-resident stores; `np.asarray` silently returns a
   host view of an FBMEM-resident store you can't actually write
   through. Dispatch on `ctx.get_variant_kind()` so each variant uses
   its own consumer — see step 4.

3. **mmap views aren't always C-contiguous** — wrap each per-file
   slice with `np.ascontiguousarray(arr[file_lo:file_hi])` before
   `.set()` or the numpy in-place write.

4. **Multi-node: `SHARD_DIR` must be on a shared filesystem.** Every
   worker (on every rank) opens shards by path; node-local `/tmp` paths
   only work for single-node demos.

## Variants

### Uniform-shard fast path (one task per file)

When every shard already has the same `(shape, dtype)` and you happen
to have `num_shards` processors available, the cum-rows / bisect
machinery is overhead. Set `tile_rows = shard_shape[0]` and
`num_tasks = num_shards`; the partition then has one tile per file
and each task reads exactly one file end-to-end (no bisect, no inner
loop). The driver-side switch is a one-liner:

```python
if all(r == per_file_rows[0] for r in per_file_rows) and num_shards == num_processors:
    tile_rows = per_file_rows[0]
else:
    tile_rows = max(1, (total_rows + num_processors - 1) // num_processors)
```

The same `load_tile` task body still works in either mode — the inner
loop just happens to iterate exactly once per task. There's no need
for a separate task body for the fast path.

### Over-decompose for better load balancing

The default `tile_rows = ceil(total_rows / num_processors)` gives one
tile per processor. To over-decompose by a factor `K` (smaller tiles,
more point tasks, finer-grained queueing), divide by `K * num_processors`
instead:

```python
tile_rows = max(1, (total_rows + K * num_processors - 1) // (K * num_processors))
```

`num_tasks = ceil(total_rows / tile_rows)` then expands to roughly
`K * num_processors`. The same task body still works — bisect just lands
on more tasks per file.

### Other formats

Only the per-file reader inside `load_tile` changes. The reader's
contract: given a file path and a half-open row range
`[file_lo, file_hi)` along axis 0, return a numpy array of shape
`(file_hi - file_lo,) + trailing_shape` that can be made C-contiguous.
Cheap range/slice reads are required — formats that only support
"read the whole file" defeat the partial-overlap case (a tile that
covers only part of one file).

| Format | Reader inside the leaf task |
|---|---|
| **`.npy`** (worked example) | `host = np.ascontiguousarray(np.load(p, mmap_mode="r")[file_lo:file_hi])` |
| **Raw binary** (fixed-shape) | `arr = np.memmap(p, dtype=DTYPE, mode="r", shape=(rows_in_file, *trailing_shape)); host = np.ascontiguousarray(arr[file_lo:file_hi])` |
| **HDF5** | `with h5py.File(p, "r") as f: host = np.ascontiguousarray(f["data"][file_lo:file_hi])` |
| **Parquet / Arrow** | `tbl = pq.read_table(p, columns=..., use_threads=False).slice(file_lo, file_hi - file_lo); host = tbl.to_pandas().values` |

(For built-in single-call loaders per format, see the "Why this skill
exists" table at the top of this file.)

The discovery step (step 1) parses each format's metadata: `.npy` /
HDF5 / Parquet all carry per-file row count + dtype on disk.
Raw binary doesn't — sidecar or derive from file size.

## Common pitfalls

### `cn.asarray(dst)` is illegal in a leaf task

Inside a `@task` body, any cupynumeric op that touches the top-level
runtime — `cn.asarray(store)`, slice assignment `cn_dst[s] = host_np` —
triggers `create_index_space` from the wrong context and Legion aborts:

```
LEGION API USAGE EXCEPTION: Invalid task context passed to runtime call
create_index_space
```

Fix: consume the DLPack capsule with a **third-party** library (cupy /
torch / numpy) inside leaf tasks. `cn.asarray` is fine in the driver,
just not in leaf tasks. See `examples/dlpack/leaf_task_interop.py` for
the torch-flavoured workaround.

### In-task `assert` aborts the runtime

Legate treats unraised exceptions in a `@task` as a contract violation
and aborts unless the task was registered with `throws_exception()`.
Sanity-check on the host before launching.

### Launch domain must match the partition tile count

`create_manual_task(launch_shape=...)` and `partition_by_tiling(...)`
are independent — the runtime doesn't catch a mismatch. Larger launch
domain → out-of-range tiles; smaller → unwritten tiles. Always derive
both from the same `(total_rows, tile_rows)` via two separate `ceil`
divisions (sizing the launch domain to `num_processors` directly
would over-launch when `num_processors > total_rows`):

```python
tile_rows = max(1, (total_rows + num_processors - 1) // num_processors)
num_tasks = (total_rows + tile_rows - 1) // tile_rows
partition = ...partition_by_tiling((tile_rows,) + trailing_shape)
runtime.create_manual_task(load_tile.library, load_tile.task_id, (num_tasks,))
```

Установить cupynumeric-parallel-data-load

Скачайте файлы навыков и распакуйте их в каталог .claude/skills/.

Скачать ZIP

Клонируйте репозиторий и скопируйте файлы навыка в свой проект.

git clone https://github.com/NVIDIA/skills/tree/main/skills/cupynumeric-parallel-data-load # Copy SKILL.md to your .claude/skills/ directory

Копировать Копировать
Быстрая настройка: Скопируйте папку со скиллом в каталог .claude/skills/ Claude автоматически обнаружит и начнет использовать этот скилл
Репозиторий NVIDIA/skills

Похожие навыки

microservices-patterns
Обновлено время 29 июня 2026 г.
jpa-patterns
Обновлено время 30 июня 2026 г.
fabric-lakehouse
Обновлено время 30 июня 2026 г.
prisma-expert
Обновлено время 29 июня 2026 г.
OR