Repository navigation
Zstd Codec on the GPU #2863
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Zstd Codec on the GPU #2863
Changes from all commits
49d5ee8
d548adc
a8c0db3
69aa274
10e1bc9
771c0c1
ec07100
d1c37a3
69ea74e
1b85fdc
f5c7814
7671274
d558ef8
f16d730
f0db57d
048ad48
2282cb9
c6460b5
3b5e294
dd825dc
f89b232
7a4b037
398b4d1
dd69543
76f7560
8b5b3f1
7af3a16
996fbc0
d24d027
090349c
83c53b0
eb50521
de3b577
ac14838
9c673ce
594ae65
202efae
d55f541
fda4350
d5b14af
6c76a6c
39789d5
c7360e2
a71b5ab
7075e3c
9262999
a153f0c
82f6ffe
aeab833
486b2bf
99ed0af
1de24ff
abf7695
d63fd21
056a602
2646c82
b0a1848
281548d
8a801c7
ee1ec92
3264865
bd34083
1b024fb
576ba5f
93d6010
314bc54
56b9e00
ef2e2f7
166c011
fbefa5e
21dea73
1ad457d
58cee0e
81e49b0
1acd153
54a8430
4d4463f
9fd0064
f2722ab
8287247
fba64c2
0f04526
7ca5cd4
b6dc00d
f87dce1
05d97a1
5f9fe39
92dbfb8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| Added GPU-accelerated Zstd Codec | ||
|
|
||
| This adds support for decoding with the Zstd Codec on NVIDIA GPUs using the | ||
| nvidia-nvcomp library. | ||
|
|
||
| With `zarr.config.enable_gpu()`, buffers will be decoded using the GPU | ||
| and the output will reside in device memory. | ||
|
|
||
| The optional-dependency extra for GPU support is now named `cuda` (``pip install "zarr[cuda]"``) | ||
| instead of `gpu`, since it installs CUDA-specific packages (CuPy and nvCOMP). The `gpu` extra | ||
| has been removed. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| # Runtime configuration | ||
| # Runtime configuration {#config-runtime-configuration} | ||
|
|
||
| [`zarr.config`][] is responsible for managing the configuration of zarr and | ||
| is based on the [donfig](https://github.com/pytroll/donfig) Python library. | ||
|
|
@@ -51,6 +51,24 @@ first register the implementations in the registry and then select them in the c | |
| For example, an implementation of the bytes codec in a class `'custompackage.NewBytesCodec'`, | ||
| requires the value of `codecs.bytes` to be `'custompackage.NewBytesCodec'`. | ||
|
|
||
| ## Codecs | ||
|
|
||
| Zarr and zarr-python split the logical codec definition from the implementation. | ||
| The Zarr metadata serialized in the store specifies just the codec name and | ||
| configuration. To resolve the specific implementation, a Python class, that's | ||
| used at runtime to encode or decode data, zarr-python looks up the codec name | ||
| in the codec registry. | ||
|
|
||
| For example, after calling `zarr.config.enable_gpu()`, an nvcomp-based | ||
| codec will be used: | ||
|
|
||
| ```python exec="true" session="config" source="above" result="ansi" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is fine. |
||
| with zarr.config.enable_gpu(): | ||
| print(zarr.config.get('codecs.zstd')) | ||
| ``` | ||
|
|
||
| ## Default Configuration | ||
|
|
||
| This is the current default configuration: | ||
|
|
||
| ```python exec="true" session="config" source="above" result="ansi" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| .. _user-guide-gpu: | ||
|
|
||
| Using GPUs with Zarr | ||
| ==================== | ||
|
|
||
| Zarr can use GPUs to accelerate your workload by running | ||
| :meth:`zarr.config.enable_gpu`. | ||
|
|
||
| Reading data into device memory | ||
| ------------------------------- | ||
|
|
||
| :meth:`zarr.config.enable_gpu` configures Zarr to use GPU memory for the data | ||
| buffers used internally by Zarr. | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| >>> import zarr | ||
| >>> import cupy as cp # doctest: +SKIP | ||
| >>> zarr.config.enable_gpu() # doctest: +SKIP | ||
| >>> store = zarr.storage.MemoryStore() # doctest: +SKIP | ||
| >>> z = zarr.create_array( # doctest: +SKIP | ||
| ... store=store, shape=(100, 100), chunks=(10, 10), dtype="float32", | ||
| ... ) | ||
| >>> type(z[:10, :10]) # doctest: +SKIP | ||
| cupy.ndarray | ||
|
|
||
| Note that the output type is a ``cupy.ndarray`` rather than a NumPy array. | ||
|
|
||
| For supported codecs, data will be decoded using the GPU via the `nvcomp`_ | ||
| library. See :ref:`user-guide-config` for more. Isseus and feature requests | ||
| for NVIDIA nvCOMP can be reported in the `nvcomp issue tracker`_. | ||
|
|
||
| .. _nvcomp: https://docs.nvidia.com/cuda/nvcomp/samples/python_samples.html | ||
| .. _nvcomp issue tracker: https://github.com/NVIDIA/CUDALibrarySamples/issues | ||
|
Comment on lines
+1
to
+34
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any new documentation should go in gpu.md. Please migrate the docs there and remove this file. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,8 +82,9 @@ remote = [ | |
| "fsspec>=2023.10.0", | ||
| "obstore>=0.5.1", | ||
| ] | ||
| gpu = [ | ||
| cuda = [ | ||
| "cupy-cuda12x; sys_platform != 'darwin'", | ||
| "nvidia-nvcomp-cu12; sys_platform != 'darwin'", | ||
| ] | ||
|
Comment on lines
+85
to
88
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we're breaking this anyway, should we spit it into cu12 and cu13? And then There's a bunch of work going on in the packaging world to make this nicer, but I'm guessing that won't be ready for a year or two.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think breaking this up is a good idea. I also don't see a harm in using a |
||
| cast-value-rs = ["cast-value-rs>=0.5.1"] | ||
| cli = ["typer"] | ||
|
|
@@ -216,7 +217,7 @@ extra-dependencies = [ | |
| # docs example is never executed on GPU hardware. | ||
| "pytest-examples", | ||
| ] | ||
| features = ["gpu"] | ||
| features = ["cuda"] | ||
|
|
||
| [[tool.hatch.envs.gputest.matrix]] | ||
| python = ["3.12", "3.13"] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| from dataclasses import dataclass | ||
| from functools import cached_property | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| import numpy as np | ||
|
|
||
| from zarr.abc.codec import BytesBytesCodec | ||
| from zarr.core.common import JSON, parse_named_configuration | ||
| from zarr.registry import register_codec | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Iterable | ||
| from typing import Self | ||
|
|
||
| from zarr.core.array_spec import ArraySpec | ||
| from zarr.core.buffer import Buffer | ||
|
|
||
| try: | ||
| import cupy as cp | ||
| except ImportError: # pragma: no cover | ||
| cp = None | ||
|
|
||
| try: | ||
| from nvidia import nvcomp | ||
| except ImportError: # pragma: no cover | ||
| nvcomp = None | ||
|
Comment on lines
+21
to
+29
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should these be lazy imports? I'm wondering about whether nvcomp / cupy should be imported when enable_gpu (or preferably enable_cuda) is false |
||
|
|
||
|
|
||
| def _parse_zstd_level(data: JSON) -> int: | ||
| if isinstance(data, int): | ||
| if data >= 23: | ||
| raise ValueError(f"Value must be less than or equal to 22. Got {data} instead.") | ||
|
TomAugspurger marked this conversation as resolved.
Resolved
|
||
| return data | ||
| raise TypeError(f"Got value with type {type(data)}, but expected an int.") | ||
|
|
||
|
|
||
| def _parse_checksum(data: JSON) -> bool: | ||
| if isinstance(data, bool): | ||
| return data | ||
| raise TypeError(f"Expected bool. Got {type(data)}.") | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class NvcompZstdCodec(BytesBytesCodec): | ||
| is_fixed_size = True | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will the difference between this default and the regular Zstd default of False cause an issue? |
||
|
|
||
| level: int = 0 | ||
| checksum: bool = False | ||
|
|
||
| def __init__(self, *, level: int = 0, checksum: bool = False) -> None: | ||
| # TODO: Set CUDA device appropriately here and also set CUDA stream | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed with leaving devices / streams as a TODO for now. I want to enable users to overlap host-to-device memcpys with compute operations (like decode, but their own compute operations as well), but I'm not sure yet what that API will look like. If you have any thoughts on how best to do this I'd love to hear them, and write them up as an issue.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #3271 for planning on devices and streams. |
||
|
|
||
| level_parsed = _parse_zstd_level(level) | ||
| checksum_parsed = _parse_checksum(checksum) | ||
|
|
||
| object.__setattr__(self, "level", level_parsed) | ||
| object.__setattr__(self, "checksum", checksum_parsed) | ||
|
Comment on lines
+56
to
+60
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should these be used somewhere? |
||
|
|
||
| @classmethod | ||
| def from_dict(cls, data: dict[str, JSON]) -> Self: | ||
| _, configuration_parsed = parse_named_configuration(data, "zstd") | ||
| return cls(**configuration_parsed) # type: ignore[arg-type] | ||
|
|
||
| def to_dict(self) -> dict[str, JSON]: | ||
| return { | ||
| "name": "zstd", | ||
| "configuration": {"level": self.level, "checksum": self.checksum}, | ||
| } | ||
|
|
||
| @cached_property | ||
| def _zstd_codec(self) -> nvcomp.Codec: | ||
| device = cp.cuda.Device() # Select the current default device | ||
|
TomAugspurger marked this conversation as resolved.
Resolved
|
||
| stream = cp.cuda.get_current_stream() # Use the current default stream | ||
| # Note: this returns an array with dtype=np.dtype("int8") | ||
| return nvcomp.Codec( | ||
| algorithm="Zstd", | ||
| bitstream_kind=nvcomp.BitstreamKind.RAW, | ||
| device_id=device.id, | ||
| cuda_stream=stream.ptr, | ||
| ) | ||
|
|
||
| def _convert_to_nvcomp_arrays( | ||
| self, | ||
| chunks_and_specs: Iterable[tuple[Buffer | None, ArraySpec]], | ||
| ) -> tuple[list[nvcomp.Array], list[int]]: | ||
| none_indices = [i for i, (b, _) in enumerate(chunks_and_specs) if b is None] | ||
| filtered_inputs = [b.as_array_like() for b, _ in chunks_and_specs if b is not None] | ||
| # TODO: add CUDA stream here | ||
| return nvcomp.as_arrays(filtered_inputs), none_indices | ||
|
|
||
| def _convert_from_nvcomp_arrays( | ||
| self, | ||
| arrays: Iterable[nvcomp.Array], | ||
| chunks_and_specs: Iterable[tuple[Buffer | None, ArraySpec]], | ||
| ) -> Iterable[Buffer | None]: | ||
| result: list[Buffer | None] = [] | ||
|
|
||
| for a, (_, spec) in zip(arrays, chunks_and_specs, strict=True): | ||
| if a is None: | ||
| result.append(None) | ||
| else: | ||
| a2 = cp.array(a, dtype=a.dtype, copy=False) | ||
| if a2.dtype != np.dtype("B"): | ||
| a2 = a2.view(dtype=np.dtype("B")) | ||
| result.append(spec.prototype.buffer.from_array_like(a2)) | ||
|
Comment on lines
+105
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously we just had I think this mismatch comes in the first place from nvcomp using |
||
|
|
||
| return result | ||
|
|
||
| async def decode( | ||
| self, | ||
| chunks_and_specs: Iterable[tuple[Buffer | None, ArraySpec]], | ||
| ) -> Iterable[Buffer | None]: | ||
| """Decodes a batch of chunks. | ||
| Chunks can be None in which case they are ignored by the codec. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| chunks_and_specs : Iterable[tuple[Buffer | None, ArraySpec]] | ||
| Ordered set of encoded chunks with their accompanying chunk spec. | ||
|
|
||
| Returns | ||
| ------- | ||
| Iterable[Buffer | None] | ||
| """ | ||
| chunks_and_specs = list(chunks_and_specs) | ||
|
|
||
| # Convert to nvcomp arrays | ||
| filtered_inputs, none_indices = self._convert_to_nvcomp_arrays(chunks_and_specs) | ||
|
|
||
| outputs = self._zstd_codec.decode(filtered_inputs) if len(filtered_inputs) > 0 else [] | ||
|
TomAugspurger marked this conversation as resolved.
Resolved
TomAugspurger marked this conversation as resolved.
Resolved
TomAugspurger marked this conversation as resolved.
Resolved
|
||
|
|
||
| # Record event for synchronization | ||
| event = cp.cuda.Event() | ||
| event.record(cp.cuda.get_current_stream()) | ||
| # Wait for decode to complete in a separate async thread | ||
| await asyncio.to_thread(event.synchronize) | ||
|
Comment on lines
+135
to
+139
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Most of what I learned as part of https://tomaugspurger.net/posts/gpu-accelerated-zarr/ was the importance of avoiding unnecessary synchronizations being important for performance. At the moment, it doesn't really matter since
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I still think we want to work towards avoiding this stream synchronization. A useful exercise would be to write down all the information we promise to be "ready" when this function returns. I suspect that that's just the shape and dtype of the arrays, but not the contents of those arrays. Ideally those would be scheduled but running asynchronously in the background when we return. |
||
|
|
||
| for index in none_indices: | ||
| outputs.insert(index, None) | ||
|
|
||
| return self._convert_from_nvcomp_arrays(outputs, chunks_and_specs) | ||
|
|
||
| async def encode( | ||
| self, | ||
| chunks_and_specs: Iterable[tuple[Buffer | None, ArraySpec]], | ||
| ) -> Iterable[Buffer | None]: | ||
| """Encodes a batch of chunks. | ||
| Chunks can be None in which case they are ignored by the codec. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| chunks_and_specs : Iterable[tuple[Buffer | None, ArraySpec]] | ||
| Ordered set of to-be-encoded chunks with their accompanying chunk spec. | ||
|
|
||
| Returns | ||
| ------- | ||
| Iterable[Buffer | None] | ||
| """ | ||
| # TODO: Make this actually async | ||
| chunks_and_specs = list(chunks_and_specs) | ||
|
|
||
| # Convert to nvcomp arrays | ||
| filtered_inputs, none_indices = self._convert_to_nvcomp_arrays(chunks_and_specs) | ||
|
|
||
| outputs = self._zstd_codec.encode(filtered_inputs) if len(filtered_inputs) > 0 else [] | ||
|
|
||
| # Record event for synchronization | ||
| event = cp.cuda.Event() | ||
| event.record(cp.cuda.get_current_stream()) | ||
| # Wait for encode to complete in a separate async thread | ||
| await asyncio.to_thread(event.synchronize) | ||
|
|
||
| for index in none_indices: | ||
| outputs.insert(index, None) | ||
|
TomAugspurger marked this conversation as resolved.
Resolved
|
||
|
|
||
| return self._convert_from_nvcomp_arrays(outputs, chunks_and_specs) | ||
|
|
||
| def compute_encoded_size(self, _input_byte_length: int, _chunk_spec: ArraySpec) -> int: | ||
| raise NotImplementedError | ||
|
|
||
|
|
||
| register_codec("zstd", NvcompZstdCodec) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please see comment below about keeping a
gpualias for backwards compatibility