Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
70abb2c
Support zarr-python 3.x
TomAugspurger Feb 19, 2025
a870ff7
Avoid memcopy in set
TomAugspurger Feb 25, 2025
0d72485
fixup
TomAugspurger Feb 25, 2025
4e9a9f2
fixup
TomAugspurger Feb 25, 2025
4e08f8c
nvtx
TomAugspurger Feb 26, 2025
9ad5c8a
updated deps
TomAugspurger Feb 26, 2025
7bbfe90
temporarily disable zarr
TomAugspurger Feb 26, 2025
e8c8f54
fixups
TomAugspurger Feb 28, 2025
47b9bd2
Refactor
TomAugspurger Feb 28, 2025
8bccefb
docs
TomAugspurger Feb 28, 2025
91852e8
Merge remote-tracking branch 'upstream/branch-25.04' into tom/zarr-v3
TomAugspurger Feb 28, 2025
e2e7bca
cleanup
TomAugspurger Feb 28, 2025
7b88b38
cleanup
TomAugspurger Feb 28, 2025
5cbe53e
Fixed v3 skip
TomAugspurger Feb 28, 2025
55e2fa8
zarr dependency
TomAugspurger Mar 3, 2025
30fddab
Merge remote-tracking branch 'upstream/branch-25.04' into tom/zarr-v3
TomAugspurger Mar 3, 2025
5eeb055
Skip fixes
TomAugspurger Mar 3, 2025
c2a59bf
Note LocalFile similarity
TomAugspurger Mar 3, 2025
e8ec00d
Add note about StoreTests
TomAugspurger Mar 3, 2025
75a5cb5
Avoid zarr alpha dep
TomAugspurger Mar 3, 2025
6bd4079
fix in dependencies.yaml
TomAugspurger Mar 3, 2025
bc92a2c
Merge remote-tracking branch 'upstream/branch-25.04' into tom/zarr-v3
TomAugspurger Mar 3, 2025
1ca12e2
Added example skip
TomAugspurger Mar 3, 2025
172eeb7
compat in benchmarks
TomAugspurger Mar 3, 2025
101d1f7
Merge remote-tracking branch 'upstream/branch-25.04' into tom/zarr-v3
TomAugspurger Mar 5, 2025
6e2e2e1
fixup
TomAugspurger Mar 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ repos:
rev: 'v1.3.0'
hooks:
- id: mypy
additional_dependencies: [types-cachetools]
additional_dependencies: [types-cachetools, zarr]
args: ["--config-file=python/kvikio/pyproject.toml",
"python/kvikio/kvikio",
"python/kvikio/tests",
Expand Down
3 changes: 2 additions & 1 deletion conda/environments/all_cuda-118_arch-aarch64.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies:
- packaging
- pre-commit
- pytest
- pytest-asyncio
- pytest-cov
- pytest-timeout
- python>=3.10,<3.13
Expand All @@ -39,5 +40,5 @@ dependencies:
- sphinx-click
- sphinx_rtd_theme
- sysroot_linux-aarch64=2.28
- zarr>=2.0.0,<3.0.0a0
- zarr>=2.0.0,<4.0.0
name: all_cuda-118_arch-aarch64
3 changes: 2 additions & 1 deletion conda/environments/all_cuda-118_arch-x86_64.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies:
- packaging
- pre-commit
- pytest
- pytest-asyncio
- pytest-cov
- pytest-timeout
- python>=3.10,<3.13
Expand All @@ -41,5 +42,5 @@ dependencies:
- sphinx-click
- sphinx_rtd_theme
- sysroot_linux-64=2.28
- zarr>=2.0.0,<3.0.0a0
- zarr>=2.0.0,<4.0.0
name: all_cuda-118_arch-x86_64
3 changes: 2 additions & 1 deletion conda/environments/all_cuda-128_arch-aarch64.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies:
- packaging
- pre-commit
- pytest
- pytest-asyncio
- pytest-cov
- pytest-timeout
- python>=3.10,<3.13
Expand All @@ -39,5 +40,5 @@ dependencies:
- sphinx-click
- sphinx_rtd_theme
- sysroot_linux-aarch64=2.28
- zarr>=2.0.0,<3.0.0a0
- zarr>=2.0.0,<4.0.0
name: all_cuda-128_arch-aarch64
3 changes: 2 additions & 1 deletion conda/environments/all_cuda-128_arch-x86_64.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies:
- packaging
- pre-commit
- pytest
- pytest-asyncio
- pytest-cov
- pytest-timeout
- python>=3.10,<3.13
Expand All @@ -39,5 +40,5 @@ dependencies:
- sphinx-click
- sphinx_rtd_theme
- sysroot_linux-64=2.28
- zarr>=2.0.0,<3.0.0a0
- zarr>=2.0.0,<4.0.0
name: all_cuda-128_arch-x86_64
2 changes: 1 addition & 1 deletion conda/recipes/kvikio/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ requirements:
- python
- numpy >=1.23,<3.0a0
- cupy >=12.0.0
- zarr >=2.0.0,<3.0.0a0
- zarr >=2.0.0,<4.0.0a0
# See https://github.com/zarr-developers/numcodecs/pull/475
- numcodecs !=0.12.0
- packaging
Expand Down
3 changes: 2 additions & 1 deletion dependencies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ dependencies:
- output_types: [conda, requirements, pyproject]
packages:
- numpy>=1.23,<3.0a0
- zarr>=2.0.0,<3.0.0a0
- zarr>=2.0.0,<4.0.0
# See https://github.com/zarr-developers/numcodecs/pull/475
- numcodecs !=0.12.0
- packaging
Expand All @@ -420,6 +420,7 @@ dependencies:
packages:
- rapids-dask-dependency==25.4.*,>=0.0.0a0
- pytest
- pytest-asyncio

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was needed to run the tests from zarr's StoreTests.

- pytest-cov
- pytest-timeout
- rangehttpserver
Expand Down
30 changes: 29 additions & 1 deletion docs/source/zarr.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,35 @@ Zarr
`Zarr-Python <https://zarr.readthedocs.io/en/stable/>`_ is the official Python package for reading and writing Zarr arrays. Its main feature is a NumPy-like array that translates array operations into file IO seamlessly.
KvikIO provides a GPU backend to Zarr-Python that enables `GPUDirect Storage (GDS) <https://developer.nvidia.com/blog/gpudirect-storage/>`_ seamlessly.

The following is an example of how to use the convenience function :py:meth:`kvikio.zarr.open_cupy_array`
KvikIO supports either zarr-python 2.x or zarr-python 3.x.
However, the API provided in :mod:`kvikio.zarr` differs based on which version of zarr you have, following the differences between zarr-python 2.x and zarr-python 3.x.


Zarr Python 3.x
---------------

Zarr-python includes native support for reading Zarr chunks into device memory if you `configure Zarr <https://zarr.readthedocs.io/en/stable/user-guide/gpu.html#>`__ to use GPUs.
You can use any store, but KvikIO provides :py:class:`kvikio.zarr.GDSStore` to efficiently load data directly into GPU memory.

.. code-block:: python

>>> import zarr
>>> from kvikio.zarr import GDSStore
>>> zarr.config.enable_gpu()
>>> store = GDSStore(root="data.zarr")
>>> z = zarr.create_array(
... store=store, shape=(100, 100), chunks=(10, 10), dtype="float32", overwrite=True
... )
>>> type(z[:10, :10])
cupy.ndarray



Zarr Python 2.x
---------------


The following uses zarr-python 2.x, and is an example of how to use the convenience function :py:meth:`kvikio.zarr.open_cupy_array`
to create a new Zarr array and how to open an existing Zarr array.


Expand Down
8 changes: 7 additions & 1 deletion python/kvikio/kvikio/benchmarks/single_node_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ def get_zarr_compressors() -> Dict[str, Any]:
import kvikio.zarr
except ImportError:
return {}
return {c.__name__.lower(): c for c in kvikio.zarr.nvcomp_compressors}
try:
compressors = kvikio.zarr.nvcomp_compressors
except AttributeError:
# zarr-python 3.x
return {}
else:
return {c.__name__.lower(): c for c in compressors}


def create_data(nbytes):
Expand Down
10 changes: 10 additions & 0 deletions python/kvikio/kvikio/zarr/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.

from importlib import metadata as _metadata

from packaging.version import Version as _Version, parse as _parse

if _parse(_metadata.version("zarr")) >= _Version("3.0.0"):
from ._zarr_python_3 import * # noqa: F401,F403
else:
from ._zarr_python_2 import * # type: ignore[assignment] # noqa: F401,F403
Comment on lines +3 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, if we're doing this, I would prefer that we have:

import kvikio.zarr # old zarr-python 2 interface, for backwards compat, warns about deprecation
import kvikio.zarr2 # move the zarr-python 2 code here
import kvikio.zarr3 # new zarr-python 3 interface

This way, a user's scripts don't suddenly break because they run on them on a machine with a different environment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have two strategies here: try to preserve source-code compatibility or not.

So under option 1, we have the potential for some user's code working with either zarr-python 2.x or 3.x, but if and only if the user limits themselves to kvikio.zarr.GDSStore and the public Zarr API that's compatible between v2 and v3 (essentially, passing in store=store to most of the group and array APIs).

If you're using any of the other features of kvikio.zarr like open_cupy_array or any of the compressors then that will break if you update to zarr-python 3.x, because we don't attempt to provide source compatibility there. And if you're using one of the zarr-python APIs that changed between zarr-python 2.x and 3.x, then stuff will break but that's outside of kvikio's hands.

We also get the (IMO) nicer name of kvikio.zarr. I dislike kvikio.zarr3 since it's not immediately clear whether the 3 refers to zarr format version or the zarr-python version (it's the zarr-python version, which can write files with zarr-format=2 or 3).

I don't have a strong preference between the two strategies. My initial implementation would have required users to import the new module name with zarr-python 3.x, but I very slightly preferred trying to preserve source compatibility for that (potentially narrow) subset of use cases where source compatibility is possible to preserve.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I guess I see that. I'm kind of on the fence because presumably at some point in the future we will only support zarr3 and then there'll be another breakage.

What do we think is our best migration strategy, if at some point in the future kvikio.zarr will mean kvikio.zarr3?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at some point in the future we will only support zarr3 and then there'll be another breakage.

What would cause the second breakage? Moving people back to kvikio.zarr, if we do the kvikio.zarr2 / kvikio.zarr3 split? IMO that wouldn't be worth the extra churn.

Here's an attempt at a summary. Under the PR as currently written, we have

time import behavior
previous kvikio.zarr only zarr-v2 supported
today kvikio.zarr zarr v2 or v3 supported. API depends on version at runtime. Some code will work with either, some code will break.
future kvikio.zarr only zarr v3 supported.

Under the split zarr.v2, zarr.v3 proposal

time import behavior
previous kvikio.zarr only zarr-v2 supported
today kvikio.zarr2 or kvikio.zarr3 zarr v2 or v3 supported. Users must update source to use the version they want.
future kvikio.zarr (or kvikio.zarr3?) only zarr v3 supported. Users must update their source if we deprecate kvikio.zarr3, or do nothing if we leave it as kvikio.zarr3.

I'm still leaning slightly towards just using kvikio.zarr thanks to that potential for maintaining source-compatibility, but won't push strongly for it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still leaning slightly towards just using kvikio.zarr thanks to that potential for maintaining source-compatibility, but won't push strongly for it.

Agree, but I have no strong opinion either :)
@wence- ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's a convincing argument in favour of the approach here. Let's go with that

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import zarr.creation
import zarr.errors
import zarr.storage
import zarr.util
from numcodecs.abc import Codec
from numcodecs.compat import ensure_contiguous_ndarray_like
from numcodecs.registry import register_codec
Expand All @@ -38,7 +37,7 @@
supported = parse(zarr.__version__) >= parse(MINIMUM_ZARR_VERSION)


class GDSStore(zarr.storage.DirectoryStore):
class GDSStore(zarr.storage.DirectoryStore): # type: ignore[name-defined]
"""GPUDirect Storage (GDS) class using directories and files.

This class works like `zarr.storage.DirectoryStore` but implements
Expand Down Expand Up @@ -338,12 +337,15 @@ def open_cupy_array(
# In order to handle "a", we start by trying to open the file in read mode.
try:
ret = zarr.open_array(
store=kvikio.zarr.GDSStore(path=store),
store=kvikio.zarr.GDSStore(path=store), # type: ignore[call-arg]
mode="r+",
meta_array=meta_array,
**kwargs,
)
except (zarr.errors.ContainsGroupError, zarr.errors.ArrayNotFoundError):
except (
zarr.errors.ContainsGroupError,
zarr.errors.ArrayNotFoundError, # type: ignore[attr-defined]
):
# If we are reading, this is a genuine error.
if mode in ("r", "r+"):
raise
Expand All @@ -356,7 +358,7 @@ def open_cupy_array(
compat_lz4 = CompatCompressor.lz4()
if ret.compressor == compat_lz4.cpu:
ret = zarr.open_array(
store=kvikio.zarr.GDSStore(
store=kvikio.zarr.GDSStore( # type: ignore[call-arg]
path=store,
compressor_config_overwrite=compat_lz4.cpu.get_config(),
decompressor_config_overwrite=compat_lz4.gpu.get_config(),
Expand Down Expand Up @@ -386,7 +388,7 @@ def open_cupy_array(
decompressor_config_overwrite = None

return zarr.open_array(
store=kvikio.zarr.GDSStore(
store=kvikio.zarr.GDSStore( # type: ignore[call-arg]
path=store,
compressor_config_overwrite=compressor_config_overwrite,
decompressor_config_overwrite=decompressor_config_overwrite,
Expand Down
135 changes: 135 additions & 0 deletions python/kvikio/kvikio/zarr/_zarr_python_3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
# See file LICENSE for terms.

import asyncio
import os
from pathlib import Path

import zarr.storage
from zarr.abc.store import (
ByteRequest,
OffsetByteRequest,
RangeByteRequest,
SuffixByteRequest,
)
from zarr.core.buffer import Buffer, BufferPrototype
from zarr.core.buffer.core import default_buffer_prototype

import kvikio

# The GDSStore implementation follows the `LocalStore` implementation
# at https://github.com/zarr-developers/zarr-python/blob/main/src/zarr/storage/_local.py
# with differences coming swapping in `cuFile` for the stdlib open file object.


def _get(
path: Path, prototype: BufferPrototype, byte_range: ByteRequest | None
) -> Buffer:
file_size = os.path.getsize(path)
file_offset: int

match byte_range:
case None:
nbytes = file_size
file_offset = 0
case OffsetByteRequest():
nbytes = max(0, file_size - byte_range.offset)
file_offset = byte_range.offset
case RangeByteRequest():
nbytes = byte_range.end - byte_range.start
file_offset = byte_range.start
case SuffixByteRequest():
nbytes = byte_range.suffix
file_offset = max(0, file_size - byte_range.suffix)
case _:
# This isn't allowed by mypy, but the tests assert we raise
# something here.
raise TypeError(f"Unexpected byte_range, got {byte_range}")
Comment thread
TomAugspurger marked this conversation as resolved.

# kvikio doesn't support reading past the end of a file. Some zarr tests
# rely on this behavior: to "read" 3 bytes out of a 0 byte file, or to
# "seek" past the end of a file with file_offset. The semantics seem to
# be roughly the same as slicing an empty bytestring.

nbytes = min(nbytes, file_size)
file_offset = min(file_offset, file_size)

raw = prototype.nd_buffer.create(shape=(nbytes,), dtype="b").as_ndarray_like()
buf = prototype.buffer.from_array_like(raw)

with kvikio.CuFile(path) as f:
# Note: this currently creates an IOFuture and then blocks
# on reading it. The blocking read means this is in a regular
# sync function, and so this must be run in a threadpool.
future = f.pread(raw, size=nbytes, file_offset=file_offset)
future.get() # blocks
Comment on lines +60 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Should we figure out a way to hook this up with the normal asyncio stuff?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe... When I went down a rabbit hole investigating zarr-developers/zarr-python#2863 (comment) I looked into this a bit and came across some discussions around supporting io_uring for asyncio on local files (python/cpython#85443, https://discuss.python.org/t/asyncio-for-files/31077). Those haven't gone anywhere.

In theory we ought to be able to do stuff with selectors and file descriptors. I'll do a bit more research and will open an issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#79 (comment) has a bit of a writeup with my findings if you're curious, but the tl/dr is that this seems pretty challenging (at least given my understanding of the C++ / CUDA side of things).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, thanks. It looks like if we would want to do that we need to return honest-to-goodness python futures from pread on the python side. There's some work changing how the C++ futures are launched that might help things here.

What I would be worried about getting right is that setting the python future status requires the gil so locking order is going to be tricky.


return buf


def _put(
Comment thread
TomAugspurger marked this conversation as resolved.
path: Path,
value: Buffer,
start: int | None = None,
exclusive: bool = False,
) -> int | None:
path.parent.mkdir(parents=True, exist_ok=True)
if start is not None:
with kvikio.CuFile(path, "r+b") as f:
f.write(value.as_array_like(), file_offset=start)
return None
else:
buf = value.as_array_like()
if exclusive:
if path.exists():
raise FileExistsError(f"File exists: {path}")
mode = "wb"
with kvikio.CuFile(path, flags=mode) as f:
return f.write(buf)


class GDSStore(zarr.storage.LocalStore):
Comment thread
madsbk marked this conversation as resolved.
def __repr__(self) -> str:
return f"{type(self).__name__}('{self}')"

async def get(
self,
key: str,
prototype: BufferPrototype | None = None,
byte_range: ByteRequest | None = None,
) -> Buffer | None:
Comment thread
TomAugspurger marked this conversation as resolved.
if prototype is None:
prototype = default_buffer_prototype()
if not self._is_open:
await self._open()
assert isinstance(key, str)
path = self.root / key

try:
return await asyncio.to_thread(_get, path, prototype, byte_range)
except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
return None

async def set(self, key: str, value: Buffer) -> None:
return await self._set(key, value)
Comment thread
TomAugspurger marked this conversation as resolved.

async def _set(self, key: str, value: Buffer, exclusive: bool = False) -> None:
if not self._is_open:
await self._open()
self._check_writable()
assert isinstance(key, str)
if not isinstance(value, Buffer):
raise TypeError(
f"LocalStore.set(): `value` must be a Buffer instance. Got an "
f"instance of {type(value)} instead."
)
path = self.root / key

await asyncio.to_thread(_put, path, value, start=None, exclusive=exclusive)
Comment thread
TomAugspurger marked this conversation as resolved.


# Matching the check that zarr.__version__ > 2.15 that's
# part of the public API for our zarr 2.x support
# This module is behind a check that zarr.__version__ > 3
# so we can just assume it's already checked and supported.
supported = True
10 changes: 9 additions & 1 deletion python/kvikio/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
"numpy>=1.23,<3.0a0",
"nvidia-nvcomp==4.2.0.11",
"packaging",
"zarr>=2.0.0,<3.0.0a0",
"zarr>=2.0.0,<4.0.0",
] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`.
classifiers = [
"Intended Audience :: Developers",
Expand All @@ -44,6 +44,7 @@ test = [
"cuda-python>=11.8.5,<12.0a0",
"moto[server]>=4.0.8",
"pytest",
"pytest-asyncio",
"pytest-cov",
"pytest-timeout",
"rangehttpserver",
Expand Down Expand Up @@ -109,6 +110,12 @@ skip = [

[tool.mypy]
ignore_missing_imports = true
exclude = [
# we type check against zarr-python 3.x
# and ignore modules using 2.x
"python/kvikio/kvikio/zarr/_zarr_python_2.py",
"python/kvikio/tests/test_nvcomp_codec.py",
]

[project.entry-points."numcodecs.codecs"]
nvcomp_batch = "kvikio.nvcomp_codec:NvCompBatchCodec"
Expand Down Expand Up @@ -157,3 +164,4 @@ filterwarnings = [
markers = [
"cufile: tests to skip if cuFile isn't available e.g. run with `pytest -m 'not cufile'`"
]
asyncio_mode = "auto"
3 changes: 2 additions & 1 deletion python/kvikio/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import multiprocessing as mp
import subprocess
from multiprocessing.connection import Connection
from typing import Iterable

import pytest
Expand All @@ -13,7 +14,7 @@
mp = mp.get_context("spawn") # type: ignore


def command_server(conn):
def command_server(conn: Connection) -> None:
"""Server to run commands given through `conn`"""
while True:
# Get the next command to run
Expand Down
Loading