Skip to content

Zstd Codec on the GPU - #2863

Open
akshaysubr wants to merge 87 commits into
zarr-developers:mainfrom
akshaysubr:gpu-codecs
Open

akshaysubr wants to merge 87 commits into
zarr-developers:mainfrom
akshaysubr:gpu-codecs

Conversation

@akshaysubr

Copy link
Copy Markdown
Contributor

This PR adds a Zstd codec that runs on the GPU using the nvCOMP 4.2 python APIs.

TODO:

  • Make fully async
  • Performance benchmarking
  • CPU-GPU roundtrip testing
  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/user-guide/*.rst
  • Changes documented as a new file in changes/
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

@github-actions github-actions Bot added the needs release notes Automatically applied to PRs which haven't added release notes label Feb 25, 2025
Comment thread src/zarr/codecs/gpu.py Outdated
@dstansby

Copy link
Copy Markdown
Contributor

Thanks for opening this PR! At the moment we do not have any codecs implemented in the zarr-python package, but instead store them in numcodecs. So although it looks like there are some zarr-python specific changes that are needed to support the new GPU codec, the actual codec should be implemented in numcodecs, and then imported in zarr-python.

@akshaysubr

Copy link
Copy Markdown
Contributor Author

@dstansby My understanding was that numcodecs is the place that python bindings to native codec implementations live and that with v3, the Codec class itself lives in zarr-python. The GPU codecs and python bindings are implemented in nvCOMP and imported through the nvidia-nvcomp-cu12 python package so I'm not sure which part of this would need to go in numcodecs. What did you have in mind?

Comment thread src/zarr/codecs/cuda.py
Comment thread src/zarr/codecs/cuda.py
Comment thread src/zarr/codecs/gpu.py Outdated
Comment thread src/zarr/codecs/cuda.py

@TomAugspurger TomAugspurger left a comment

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.

Looks nice overall. I think the async side of things ended up in a pretty good spot. The code itself is pretty easy to follow (a normal stream synchronize). Having to do that on another host thread is a bit unfortunate, but there's only one synchronize per batch so this should be fine.

I left comments on a few things to clean up that I can help out with if you want @akshaysubr.

Comment thread src/zarr/codecs/cuda.py
Comment thread src/zarr/codecs/gpu.py Outdated
Comment thread src/zarr/codecs/cuda.py
checksum: bool = False

def __init__(self, *, level: int = 0, checksum: bool = False) -> None:
# TODO: Set CUDA device appropriately here and also set CUDA stream

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.

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.

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.

#3271 for planning on devices and streams.

Comment thread src/zarr/codecs/gpu.py Outdated
chunks_and_specs: Iterable[tuple[Buffer | None, ArraySpec]],
) -> Iterable[Buffer | None]:
return [
spec.prototype.buffer.from_array_like(cp.array(a, dtype=np.dtype("b"), copy=False))

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.

This is one spot where @weiji14's idea to use dlpack in #2658 (comment) would help. If NDBuffer new how to consume objects implementing the dlpack protocol, we could (maybe) get rid of the cp.array

Comment thread src/zarr/codecs/gpu.py Outdated
Comment thread src/zarr/codecs/gpu.py Outdated
Comment thread src/zarr/codecs/gpu.py Outdated
Comment thread src/zarr/codecs/gpu.py Outdated
Comment thread src/zarr/codecs/gpu.py Outdated
@TomAugspurger

Copy link
Copy Markdown
Contributor

Fixed some merge conflicts and changed the _convert_{to,from}_nvcomp_arrays methods to be sync.

if array_like.ndim != 1:
raise ValueError("array_like: only 1-dim allowed")
if array_like.dtype != np.dtype("B"):
if array_like.dtype.itemsize != 1:

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.

The new tests in test_nvcomp.py were failing without this change.

I'd like to get us to a point where we don't care as much about the details of the buffer passed in here. This is an OK start I think.

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.

what exactly does this check for? It's not clear to me why any numpy array that can be viewed as bytes wouldn't be allowed in here

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.

and same for the dimensionality check, since any N-dimensional numpy array can be viewed as a 1D array.

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, I'm not really sure...

I agree that the actual data we store internally here needs to be a byte dtype. Just doing cp.asarray(input).view("b") seems pretty reasonable to me.

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.

i'm not even convinced that we need Buffer / NDBuffer, when Buffer is just a special case of NDBuffer where there's 1 dimension and the data type is bytes

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.

we could even express this formally by:

  • making NDBuffer generic with two type parameters (number of dimensions and dtype)
  • having APIs that insist on consuming a Buffer instead insist on consuming NDBuffer[Literal[1], np.uint8]

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.

(super out of scope for this PR ofc)

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.

It's not clear to me why any numpy array that can be viewed as bytes wouldn't be allowed in here

I think this is mainly because NDBuffer objects don't need to be contiguous, but Buffer objects must be contiguous in memory which might be important when we send those out to codecs that expect a contiguous memory slice.

But I agree that we can probably merge those two and make Buffer a specialization of NDBuffer.

@TomAugspurger
TomAugspurger marked this pull request as ready for review July 9, 2025 19:38
@codecov

codecov Bot commented Jul 9, 2025 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.66%. Comparing base (8f14d67) to head (5f9fe39).
⚠️ Report is 352 commits behind head on main.

❗ There is a different number of reports uploaded between BASE (8f14d67) and HEAD (5f9fe39). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (8f14d67) HEAD (5f9fe39)
tests 7 6
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2863      +/-   ##
==========================================
- Coverage   92.91%   83.66%   -9.25%     
==========================================
  Files          86       98      +12     
  Lines       10707    19415    +8708     
==========================================
+ Hits         9948    16243    +6295     
- Misses        759     3172    +2413     

see 77 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

TomAugspurger and others added 22 commits August 25, 2026 15:58
…ed config

- decode()/encode() created a cp.cuda.Event() but never called .record()
  before .synchronize(), so the "wait for GPU completion" was a no-op --
  a race, not just wasted asyncio.to_thread overhead. Now records on the
  stream the nvcomp op was enqueued on before waiting.
- _zstd_codec was a cached_property binding device/stream once, forever,
  even though codec/pipeline instances are cached and reused (e.g. by
  sharding's pipeline cache). Now built fresh per call from the caller's
  current device/stream.
- level/checksum were parsed and round-tripped through to_dict/from_dict
  but never passed to nvcomp.Codec(...). checksum now maps to nvcomp's
  ChecksumPolicy; level has no nvCOMP equivalent, so a nonzero value now
  raises NotImplementedError instead of being silently dropped.
- is_fixed_size was True for a compressor (encoded size is data-dependent,
  same as the CPU ZstdCodec's is_fixed_size = False); left is_fixed_size
  True made compute_encoded_size's NotImplementedError reachable by any
  caller that correctly trusts is_fixed_size before calling it.
# Conflicts:
#	docs/user-guide/gpu.md
#	pyproject.toml
#	src/zarr/codecs/__init__.py
#	src/zarr/codecs/gpu.py
#	src/zarr/core/buffer/gpu.py
#	tests/test_codecs/test_codecs.py
#	tests/test_codecs/test_nvcomp.py
# Conflicts:
#	src/zarr/core/buffer/gpu.py
#	tests/test_api.py
…of cast

Answers review question on why the cast was needed: get_codec_class returns
type[Codec]; the registry lookup exists so enable_gpu() can swap in
NvcompZstdCodec. A runtime isinstance check replaces the static cast.
@negin513

Copy link
Copy Markdown

@akshaysubr I've opened akshaysubr#4 against this PR's gpu-codecs branch: it merges current main (resolving the conflicts here), carries @TomAugspurger's follow-up commits that hadn't been pushed to this branch, and fixes a few NvcompZstdCodec issues (unrecorded sync event, cached device/stream, ignored level/checksum config, is_fixed_size). CPU tests/lint/mypy pass locally; GPU tests still need a run on GPU hardware. Merging it into gpu-codecs will update this PR.

negin513 and others added 7 commits October 14, 2026 14:46
Per review discussion on zarr-developers#2863 (JeppeKlitgaard, d-v-b, TomAugspurger): the
extra installs CUDA-specific packages (cupy-cuda12x, nvidia-nvcomp-cu12), so
name it after CUDA rather than GPUs in general, leaving room for other
accelerator backends. The 'gpu' extra is removed (extras can't be deprecated).

enable_gpu() and zarr.buffer.gpu are unchanged: they predate this PR and are
public API.
Raising NotImplementedError in __init__ also fired from from_dict, so any
array written by the CPU ZstdCodec with level != 0 could not be opened
under enable_gpu(). level is an encode-only parameter; accept and preserve
it in metadata as the CPU codec does. Restore the device/stream TODO.
nvCOMP only allows NO_COMPUTE_NO_VERIFY with a RAW bitstream, so mapping
checksum to ChecksumPolicy made checksum=True fail on both encode and
decode. The CUDA event is still recorded before synchronizing.
Sync gpu-codecs with main, rename the gpu extra to cuda, fix nvCOMP Zstd sync and options

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants