From e1b2e71d94cb8420fcbb4dde6850b9082abc0de5 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 24 Oct 2026 22:07:01 -0400 Subject: [PATCH] Add XXHash64, XXHash3, and XXHash128 to smithy-checksums Adds the XXHash checksum algorithms S3 supports, backed by the native extension (twox-hash) with a pure-Python fallback ported from the reference implementation, and available through new(). The pure-Python implementations are checked against the native ones and against the CRT. --- ...ture-4efedfc0ab3648858d67c42495fb580b.json | 4 + packages/smithy-checksums/Cargo.lock | 7 + packages/smithy-checksums/Cargo.toml | 1 + packages/smithy-checksums/LICENSE-THIRD-PARTY | 29 ++ packages/smithy-checksums/README.md | 5 +- .../build_backend/smithy_checksums_build.py | 3 +- packages/smithy-checksums/pyproject.toml | 2 +- packages/smithy-checksums/rust/lib.rs | 60 ++- .../src/smithy_checksums/__init__.py | 51 +- .../src/smithy_checksums/_native.pyi | 15 + .../src/smithy_checksums/_private/backend.py | 8 +- .../src/smithy_checksums/_private/xxhash.py | 487 ++++++++++++++++++ .../tests/unit/test_checksums.py | 9 + .../tests/unit/test_implementations.py | 11 +- .../smithy-checksums/tests/unit/vectors.py | 151 +++++- scripts/api-snapshot/api.json | 3 + 16 files changed, 824 insertions(+), 22 deletions(-) create mode 100644 packages/smithy-checksums/.changes/next-release/smithy-checksums-feature-4efedfc0ab3648858d67c42495fb580b.json create mode 100644 packages/smithy-checksums/src/smithy_checksums/_private/xxhash.py diff --git a/packages/smithy-checksums/.changes/next-release/smithy-checksums-feature-4efedfc0ab3648858d67c42495fb580b.json b/packages/smithy-checksums/.changes/next-release/smithy-checksums-feature-4efedfc0ab3648858d67c42495fb580b.json new file mode 100644 index 000000000..e4082d116 --- /dev/null +++ b/packages/smithy-checksums/.changes/next-release/smithy-checksums-feature-4efedfc0ab3648858d67c42495fb580b.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added XXHash64, XXHash3, and XXHash128 checksums." +} \ No newline at end of file diff --git a/packages/smithy-checksums/Cargo.lock b/packages/smithy-checksums/Cargo.lock index 792443944..d1e017f18 100644 --- a/packages/smithy-checksums/Cargo.lock +++ b/packages/smithy-checksums/Cargo.lock @@ -146,6 +146,7 @@ version = "0.0.0" dependencies = [ "crc-fast", "pyo3", + "twox-hash", ] [[package]] @@ -171,6 +172,12 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + [[package]] name = "typenum" version = "1.20.1" diff --git a/packages/smithy-checksums/Cargo.toml b/packages/smithy-checksums/Cargo.toml index 7fb945a43..fda015c71 100644 --- a/packages/smithy-checksums/Cargo.toml +++ b/packages/smithy-checksums/Cargo.toml @@ -14,6 +14,7 @@ path = "rust/lib.rs" [dependencies] crc-fast = { version = "1.10", default-features = false, features = ["std"] } pyo3 = { version = "0.30", features = ["abi3-py312", "abi3t-py315"] } +twox-hash = { version = "2.1", default-features = false, features = ["std", "xxhash64", "xxhash3_64", "xxhash3_128"] } [profile.release] codegen-units = 1 diff --git a/packages/smithy-checksums/LICENSE-THIRD-PARTY b/packages/smithy-checksums/LICENSE-THIRD-PARTY index bf00306c1..f899ad096 100644 --- a/packages/smithy-checksums/LICENSE-THIRD-PARTY +++ b/packages/smithy-checksums/LICENSE-THIRD-PARTY @@ -958,6 +958,35 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +MIT License (MIT) + +Used by: + - twox-hash 2.1.5 (https://github.com/shepmaster/twox-hash) + +The MIT License (MIT) + +Copyright (c) 2015 Jake Goulding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ================================================================================ MIT License (MIT) diff --git a/packages/smithy-checksums/README.md b/packages/smithy-checksums/README.md index 1e579b6d1..609a37ddc 100644 --- a/packages/smithy-checksums/README.md +++ b/packages/smithy-checksums/README.md @@ -1,7 +1,8 @@ # smithy-checksums This package provides the checksum algorithms used by Smithy clients: CRC32, -CRC32C, CRC64NVME, and, through `new()`, SHA-1, SHA-256, SHA-512, and MD5. +CRC32C, CRC64NVME, XXHash64, XXHash3, XXHash128, and, through `new()`, SHA-1, +SHA-256, SHA-512, and MD5. ```python import smithy_checksums @@ -13,7 +14,7 @@ checksum.digest() ## Native acceleration -The CRC algorithms are implemented in Rust and published as wheels for +The CRC and XXHash algorithms are implemented in Rust and published as wheels for common platforms. Where no wheel is available, the package installs as pure Python and produces identical results, much more slowly. `HAS_NATIVE_EXTENSION` reports which implementation is in use. CRC32 always uses `zlib` in pure Python, and the diff --git a/packages/smithy-checksums/build_backend/smithy_checksums_build.py b/packages/smithy-checksums/build_backend/smithy_checksums_build.py index 0eb5ca38f..a95e1d925 100644 --- a/packages/smithy-checksums/build_backend/smithy_checksums_build.py +++ b/packages/smithy-checksums/build_backend/smithy_checksums_build.py @@ -36,7 +36,8 @@ _CARGO_TOML = Path(__file__).parent.parent / "Cargo.toml" _PURE_PYTHON_HINT = ( f"To install the pure-Python implementation instead, set {_ENV_VAR}=never. It " - "produces identical checksums, but CRC32C and CRC64NVME are much slower." + "produces identical checksums, but CRC32C, CRC64NVME, and the XXHash algorithms " + "are much slower." ) diff --git a/packages/smithy-checksums/pyproject.toml b/packages/smithy-checksums/pyproject.toml index 3d3561542..97950b564 100644 --- a/packages/smithy-checksums/pyproject.toml +++ b/packages/smithy-checksums/pyproject.toml @@ -9,7 +9,7 @@ authors = [ description = "Checksum algorithms for Smithy tooling, with native acceleration." readme = "README.md" license = {text = "Apache License 2.0"} -keywords = ["smithy", "sdk", "checksum", "crc32c", "crc64nvme"] +keywords = ["smithy", "sdk", "checksum", "crc32c", "crc64nvme", "xxhash"] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", diff --git a/packages/smithy-checksums/rust/lib.rs b/packages/smithy-checksums/rust/lib.rs index 0d170bcc9..9de72864f 100644 --- a/packages/smithy-checksums/rust/lib.rs +++ b/packages/smithy-checksums/rust/lib.rs @@ -4,8 +4,10 @@ //! Native checksum implementations for `smithy_checksums`. //! //! Each class mirrors the pure-Python implementation of the same name in -//! `smithy_checksums._private.crc`, so the two are interchangeable. +//! `smithy_checksums._private.crc` or `smithy_checksums._private.xxhash`, so +//! the two are interchangeable. +use std::hash::Hasher as _; use std::sync::{Mutex, MutexGuard}; use pyo3::buffer::PyBuffer; @@ -50,6 +52,57 @@ crc_state!(Crc32State, Crc32IsoHdlc, 4); crc_state!(Crc32cState, Crc32Iscsi, 4); crc_state!(Crc64NvmeState, Crc64Nvme, 8); +#[derive(Clone)] +struct XxHash64State(twox_hash::XxHash64); + +impl State for XxHash64State { + fn new() -> Self { + Self(twox_hash::XxHash64::with_seed(0)) + } + + fn update(&mut self, data: &[u8]) { + self.0.write(data); + } + + fn digest(&self) -> Vec { + self.0.finish().to_be_bytes().to_vec() + } +} + +#[derive(Clone)] +struct XxHash3State(twox_hash::XxHash3_64); + +impl State for XxHash3State { + fn new() -> Self { + Self(twox_hash::XxHash3_64::new()) + } + + fn update(&mut self, data: &[u8]) { + self.0.write(data); + } + + fn digest(&self) -> Vec { + self.0.finish().to_be_bytes().to_vec() + } +} + +#[derive(Clone)] +struct XxHash128State(twox_hash::XxHash3_128); + +impl State for XxHash128State { + fn new() -> Self { + Self(twox_hash::XxHash3_128::new()) + } + + fn update(&mut self, data: &[u8]) { + self.0.write(data); + } + + fn digest(&self) -> Vec { + self.0.finish_128().to_be_bytes().to_vec() + } +} + fn lock(state: &Mutex) -> MutexGuard<'_, S> { // A panic while holding the lock can't leave a checksum half-updated in a // way that matters more than the panic itself, so ignore poisoning. @@ -129,9 +182,12 @@ macro_rules! checksum_class { checksum_class!(Crc32, Crc32State); checksum_class!(Crc32C, Crc32cState); checksum_class!(Crc64Nvme, Crc64NvmeState); +checksum_class!(XxHash64, XxHash64State); +checksum_class!(XxHash3, XxHash3State); +checksum_class!(XxHash128, XxHash128State); #[pymodule] mod _native { #[pymodule_export] - use super::{Crc32, Crc32C, Crc64Nvme}; + use super::{Crc32, Crc32C, Crc64Nvme, XxHash3, XxHash64, XxHash128}; } diff --git a/packages/smithy-checksums/src/smithy_checksums/__init__.py b/packages/smithy-checksums/src/smithy_checksums/__init__.py index 74a7c6e9c..58fdeb838 100644 --- a/packages/smithy-checksums/src/smithy_checksums/__init__.py +++ b/packages/smithy-checksums/src/smithy_checksums/__init__.py @@ -2,11 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 """Checksum algorithms for Smithy clients. -CRC32, CRC32C, and CRC64NVME use a native extension when one is available for the -platform and fall back to pure Python otherwise. The fallback produces identical -results but is orders of magnitude slower, except for CRC32, which uses :mod:`zlib`. -SHA-1, SHA-256, SHA-512, and MD5 are available through :func:`new` and always use -:mod:`hashlib`. +CRC32, CRC32C, CRC64NVME, XXHash64, XXHash3, and XXHash128 use a native extension when +one is available for the platform and fall back to pure Python otherwise. The +fallback produces identical results but is orders of magnitude slower, except for +CRC32, which uses :mod:`zlib`. SHA-1, SHA-256, SHA-512, and MD5 are available +through :func:`new` and always use :mod:`hashlib`. """ import hashlib @@ -26,13 +26,17 @@ "CRC64NVME", "HAS_NATIVE_EXTENSION", "Checksum", + "XXHash3", + "XXHash64", + "XXHash128", "new", ) HAS_NATIVE_EXTENSION: Final[bool] = _NATIVE """Whether the native extension is in use. -When it isn't, CRC32C and CRC64NVME are computed in pure Python. +When it isn't, CRC32C, CRC64NVME, XXHash64, XXHash3, and XXHash128 are computed in +pure Python. """ @@ -132,7 +136,36 @@ class CRC64NVME(_BackendChecksum): digest_size = 8 -_CHECKSUMS: Final = {cls.name: cls for cls in (CRC32, CRC32C, CRC64NVME)} +class XXHash64(_BackendChecksum): + """XXH64 with a seed of zero.""" + + __slots__ = () + + name = "xxhash64" + digest_size = 8 + + +class XXHash3(_BackendChecksum): + """XXH3 (64-bit) with a seed of zero and the default secret.""" + + __slots__ = () + + name = "xxhash3" + digest_size = 8 + + +class XXHash128(_BackendChecksum): + """XXH3 (128-bit) with a seed of zero and the default secret.""" + + __slots__ = () + + name = "xxhash128" + digest_size = 16 + + +_CHECKSUMS: Final = { + cls.name: cls for cls in (CRC32, CRC32C, CRC64NVME, XXHash64, XXHash3, XXHash128) +} _HASHLIB_ALGORITHMS: Final = frozenset(("sha1", "sha256", "sha512")) @@ -142,8 +175,8 @@ def new(name: str) -> Checksum: Names are case-insensitive, so the values of a checksum algorithm enum such as ``CRC64NVME`` or ``SHA256`` can be passed directly. - :param name: One of ``crc32``, ``crc32c``, ``crc64nvme``, ``sha1``, ``sha256``, - ``sha512``, or ``md5``. + :param name: One of ``crc32``, ``crc32c``, ``crc64nvme``, ``xxhash64``, + ``xxhash3``, ``xxhash128``, ``sha1``, ``sha256``, ``sha512``, or ``md5``. :raises ValueError: If the algorithm isn't supported. """ key = name.lower() diff --git a/packages/smithy-checksums/src/smithy_checksums/_native.pyi b/packages/smithy-checksums/src/smithy_checksums/_native.pyi index 48214f330..67aa37d15 100644 --- a/packages/smithy-checksums/src/smithy_checksums/_native.pyi +++ b/packages/smithy-checksums/src/smithy_checksums/_native.pyi @@ -20,3 +20,18 @@ class Crc64Nvme: def update(self, data: _Bytes, /) -> None: ... def digest(self) -> bytes: ... def copy(self) -> Self: ... + +class XxHash64: + def update(self, data: _Bytes, /) -> None: ... + def digest(self) -> bytes: ... + def copy(self) -> Self: ... + +class XxHash3: + def update(self, data: _Bytes, /) -> None: ... + def digest(self) -> bytes: ... + def copy(self) -> Self: ... + +class XxHash128: + def update(self, data: _Bytes, /) -> None: ... + def digest(self) -> bytes: ... + def copy(self) -> Self: ... diff --git a/packages/smithy-checksums/src/smithy_checksums/_private/backend.py b/packages/smithy-checksums/src/smithy_checksums/_private/backend.py index 82b5d85a3..61a9fa5a3 100644 --- a/packages/smithy-checksums/src/smithy_checksums/_private/backend.py +++ b/packages/smithy-checksums/src/smithy_checksums/_private/backend.py @@ -26,18 +26,24 @@ def _load() -> tuple[bool, Mapping[str, ImplFactory]]: try: from smithy_checksums import _native except ImportError: - from smithy_checksums._private import crc + from smithy_checksums._private import crc, xxhash return False, { "crc32": crc.Crc32, "crc32c": crc.Crc32C, "crc64nvme": crc.Crc64Nvme, + "xxhash64": xxhash.XxHash64, + "xxhash3": xxhash.XxHash3, + "xxhash128": xxhash.XxHash128, } return True, { "crc32": _native.Crc32, "crc32c": _native.Crc32C, "crc64nvme": _native.Crc64Nvme, + "xxhash64": _native.XxHash64, + "xxhash3": _native.XxHash3, + "xxhash128": _native.XxHash128, } diff --git a/packages/smithy-checksums/src/smithy_checksums/_private/xxhash.py b/packages/smithy-checksums/src/smithy_checksums/_private/xxhash.py new file mode 100644 index 000000000..0d546c772 --- /dev/null +++ b/packages/smithy-checksums/src/smithy_checksums/_private/xxhash.py @@ -0,0 +1,487 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Pure-Python XXH64, XXH3-64, and XXH3-128, used when the native extension is unavailable. + +These follow the reference implementation (https://github.com/Cyan4973/xxHash) with +a seed of zero and the default secret, which is all that checksums need. +""" + +from collections.abc import Buffer +from struct import Struct +from typing import Self + +from .pure import PureChecksum + +_MASK64 = 0xFFFFFFFFFFFFFFFF + +_PRIME32_1 = 0x9E3779B1 +_PRIME32_2 = 0x85EBCA77 +_PRIME32_3 = 0xC2B2AE3D +_PRIME64_1 = 0x9E3779B185EBCA87 +_PRIME64_2 = 0xC2B2AE3D27D4EB4F +_PRIME64_3 = 0x165667B19E3779F9 +_PRIME64_4 = 0x85EBCA77C2B2AE63 +_PRIME64_5 = 0x27D4EB2F165667C5 +_PRIME_MX1 = 0x165667919E3779F9 +_PRIME_MX2 = 0x9FB21C651E98DF25 + +_U32 = Struct(" int: + return ((value << bits) | (value >> (64 - bits))) & _MASK64 + + +def _read32(data: Buffer, offset: int) -> int: + return _U32.unpack_from(data, offset)[0] + + +def _read64(data: Buffer, offset: int) -> int: + return _U64.unpack_from(data, offset)[0] + + +def _swap32(value: int) -> int: + return int.from_bytes(value.to_bytes(4, "little"), "big") + + +def _swap64(value: int) -> int: + return int.from_bytes(value.to_bytes(8, "little"), "big") + + +def _fold64(lhs: int, rhs: int) -> int: + """Multiplies two 64-bit values and XORs the halves of the 128-bit product.""" + product = lhs * rhs + return (product ^ (product >> 64)) & _MASK64 + + +def _xxh64_avalanche(value: int) -> int: + value ^= value >> 33 + value = (value * _PRIME64_2) & _MASK64 + value ^= value >> 29 + value = (value * _PRIME64_3) & _MASK64 + return value ^ (value >> 32) + + +def _xxh3_avalanche(value: int) -> int: + value ^= value >> 37 + value = (value * _PRIME_MX1) & _MASK64 + return value ^ (value >> 32) + + +# -------------------------------------------------------------------------------------- +# XXH64 +# -------------------------------------------------------------------------------------- + + +def _xxh64_round(acc: int, lane: int) -> int: + acc = (acc + lane * _PRIME64_2) & _MASK64 + return (_rotl64(acc, 31) * _PRIME64_1) & _MASK64 + + +def _xxh64_merge_round(acc: int, value: int) -> int: + acc ^= _xxh64_round(0, value) + return (acc * _PRIME64_1 + _PRIME64_4) & _MASK64 + + +class XxHash64(PureChecksum): + """XXH64 with a seed of zero.""" + + __slots__ = ("_acc", "_buffer", "_length") + + def __init__(self) -> None: + super().__init__() + self._acc: list[int] = [ + (_PRIME64_1 + _PRIME64_2) & _MASK64, + _PRIME64_2, + 0, + (-_PRIME64_1) & _MASK64, + ] + self._buffer = b"" + self._length = 0 + + def _update(self, data: bytes | bytearray) -> None: + data = self._buffer + data + self._length += len(data) - len(self._buffer) + stripes = len(data) // 32 + if stripes: + v1, v2, v3, v4 = self._acc + for l1, l2, l3, l4 in _U64X4.iter_unpack(memoryview(data)[: stripes * 32]): + v1 = _xxh64_round(v1, l1) + v2 = _xxh64_round(v2, l2) + v3 = _xxh64_round(v3, l3) + v4 = _xxh64_round(v4, l4) + self._acc = [v1, v2, v3, v4] + self._buffer = bytes(data[stripes * 32 :]) + + def _digest(self) -> bytes: + if self._length >= 32: + v1, v2, v3, v4 = self._acc + acc = ( + _rotl64(v1, 1) + _rotl64(v2, 7) + _rotl64(v3, 12) + _rotl64(v4, 18) + ) & _MASK64 + for value in self._acc: + acc = _xxh64_merge_round(acc, value) + else: + acc = _PRIME64_5 + acc = (acc + self._length) & _MASK64 + + tail = self._buffer + offset = 0 + while offset + 8 <= len(tail): + acc ^= _xxh64_round(0, _read64(tail, offset)) + acc = (_rotl64(acc, 27) * _PRIME64_1 + _PRIME64_4) & _MASK64 + offset += 8 + if offset + 4 <= len(tail): + acc ^= (_read32(tail, offset) * _PRIME64_1) & _MASK64 + acc = (_rotl64(acc, 23) * _PRIME64_2 + _PRIME64_3) & _MASK64 + offset += 4 + for byte in tail[offset:]: + acc ^= (byte * _PRIME64_5) & _MASK64 + acc = (_rotl64(acc, 11) * _PRIME64_1) & _MASK64 + + return _xxh64_avalanche(acc).to_bytes(8, "big") + + def _copy(self) -> Self: + new = type(self)() + new._acc = list(self._acc) + new._buffer = self._buffer + new._length = self._length + return new + + +# -------------------------------------------------------------------------------------- +# XXH3 (shared by the 64- and 128-bit variants) +# -------------------------------------------------------------------------------------- + +_SECRET = bytes.fromhex( + "b8fe6c3923a44bbe7c01812cf721ad1cded46de9839097db7240a4a4b7b3671f" + "cb79e64eccc0e578825ad07dccff7221b8084674f743248ee03590e6813a264c" + "3c2852bb91c300cb88d0658b1b532ea371644897a20df94e3819ef46a9deacd8" + "a8fa763fe39c343ff9dcbbc7c70b4f1d8a51e04bcdb45931c89f7ec9d9787364" + "eac5ac8334d3ebc3c581a0fffa1363eb170ddd51b7f0da49d316552629d4689e" + "2b16be587d47a1fc8ff8b8d17ad031ce45cb3a8f95160428afd7fbcabb4b407e" +) +_SECRET_SIZE = len(_SECRET) +_STRIPE_LEN = 64 +_STRIPES_PER_BLOCK = (_SECRET_SIZE - _STRIPE_LEN) // 8 +_MIDSIZE_MAX = 240 +# Input is buffered until it's clear the long-input algorithm applies, and then +# processed in chunks of at least this many bytes. +_BUFFER_SIZE = 1024 + +# The secret words each stripe is keyed with, and the ones used to scramble the +# accumulators at the end of a block and to key the last stripe. +_STRIPE_SECRETS = tuple( + _U64X8.unpack_from(_SECRET, stripe * 8) for stripe in range(_STRIPES_PER_BLOCK) +) +_SCRAMBLE_SECRET = _U64X8.unpack_from(_SECRET, _SECRET_SIZE - _STRIPE_LEN) +_LAST_STRIPE_SECRET = _U64X8.unpack_from(_SECRET, _SECRET_SIZE - _STRIPE_LEN - 7) + +_INITIAL_ACC: tuple[int, ...] = ( + _PRIME32_3, + _PRIME64_1, + _PRIME64_2, + _PRIME64_3, + _PRIME64_4, + _PRIME32_2, + _PRIME64_5, + _PRIME32_1, +) + + +def _mix16(data: bytes, offset: int, secret_offset: int) -> int: + return _fold64( + _read64(data, offset) ^ _read64(_SECRET, secret_offset), + _read64(data, offset + 8) ^ _read64(_SECRET, secret_offset + 8), + ) + + +def _accumulate_stripe( + acc: list[int], stripe: tuple[int, ...], secret: tuple[int, ...] +) -> None: + for lane in range(8): + value = stripe[lane] + keyed = value ^ secret[lane] + acc[lane ^ 1] = (acc[lane ^ 1] + value) & _MASK64 + acc[lane] = (acc[lane] + (keyed & 0xFFFFFFFF) * (keyed >> 32)) & _MASK64 + + +def _scramble(acc: list[int]) -> None: + for lane in range(8): + value = acc[lane] + value ^= value >> 47 + value ^= _SCRAMBLE_SECRET[lane] + acc[lane] = (value * _PRIME32_1) & _MASK64 + + +def _merge_accs(acc: list[int], secret_offset: int, start: int) -> int: + result = start + for pair in range(4): + result += _fold64( + acc[2 * pair] ^ _read64(_SECRET, secret_offset + 16 * pair), + acc[2 * pair + 1] ^ _read64(_SECRET, secret_offset + 16 * pair + 8), + ) + return _xxh3_avalanche(result & _MASK64) + + +class _Xxh3Base(PureChecksum): + """Streaming state for XXH3 inputs longer than 240 bytes. + + Inputs of up to 240 bytes are hashed in one shot from the buffer. Longer inputs + are consumed one 64-byte stripe at a time, always leaving at least one byte + unconsumed so the final stripe can be keyed differently, as the algorithm + requires. + """ + + __slots__ = ("_acc", "_buffer", "_last_consumed", "_length", "_stripes") + + def __init__(self) -> None: + super().__init__() + self._acc = list(_INITIAL_ACC) + self._buffer = b"" + # The trailing bytes of consumed input, needed to build the final stripe + # when fewer than 64 bytes remain buffered. + self._last_consumed = b"" + self._length = 0 + # The number of stripes consumed in the current block. + self._stripes = 0 + + def _update(self, data: bytes | bytearray) -> None: + data = self._buffer + data + self._length += len(data) - len(self._buffer) + if len(data) > _BUFFER_SIZE: + data = self._consume(data) + self._buffer = bytes(data) + + def _consume(self, data: bytes | bytearray) -> bytes | bytearray: + """Consumes every complete stripe in ``data`` that has at least one byte after it.""" + stripes = (len(data) - 1) // _STRIPE_LEN + if not stripes: + return data + end = stripes * _STRIPE_LEN + acc = self._acc + current = self._stripes + for stripe in _U64X8.iter_unpack(memoryview(data)[:end]): + _accumulate_stripe(acc, stripe, _STRIPE_SECRETS[current]) + current += 1 + if current == _STRIPES_PER_BLOCK: + _scramble(acc) + current = 0 + self._stripes = current + self._last_consumed = bytes(data[end - _STRIPE_LEN : end]) + return data[end:] + + def _long_acc(self) -> list[int]: + """Returns the accumulators after all input, including the final stripe.""" + state = self._copy() + remaining = state._consume(state._buffer) + last_stripe = (state._last_consumed + remaining)[-_STRIPE_LEN:] + _accumulate_stripe(state._acc, _U64X8.unpack(last_stripe), _LAST_STRIPE_SECRET) + return state._acc + + def _copy(self) -> Self: + new = type(self)() + new._acc = list(self._acc) + new._buffer = self._buffer + new._last_consumed = self._last_consumed + new._length = self._length + new._stripes = self._stripes + return new + + +# -------------------------------------------------------------------------------------- +# XXH3-64 +# -------------------------------------------------------------------------------------- + + +def _xxh3_64_short(data: bytes) -> int: + length = len(data) + if length > 128: + return _xxh3_64_129_to_240(data) + if length > 16: + return _xxh3_64_17_to_128(data) + if length > 8: + low = _read64(data, 0) ^ (_read64(_SECRET, 24) ^ _read64(_SECRET, 32)) + high = _read64(data, length - 8) ^ (_read64(_SECRET, 40) ^ _read64(_SECRET, 48)) + acc = length + _swap64(low) + high + _fold64(low, high) + return _xxh3_avalanche(acc & _MASK64) + if length >= 4: + combined = _read32(data, length - 4) + (_read32(data, 0) << 32) + value = combined ^ (_read64(_SECRET, 8) ^ _read64(_SECRET, 16)) + value ^= _rotl64(value, 49) ^ _rotl64(value, 24) + value = (value * _PRIME_MX2) & _MASK64 + value ^= (value >> 35) + length + value = (value * _PRIME_MX2) & _MASK64 + return value ^ (value >> 28) + if length: + combined = ( + (data[0] << 16) | (data[length >> 1] << 24) | data[-1] | (length << 8) + ) + return _xxh64_avalanche(combined ^ (_read32(_SECRET, 0) ^ _read32(_SECRET, 4))) + return _xxh64_avalanche(_read64(_SECRET, 56) ^ _read64(_SECRET, 64)) + + +def _xxh3_64_17_to_128(data: bytes) -> int: + length = len(data) + acc = length * _PRIME64_1 + if length > 32: + if length > 64: + if length > 96: + acc += _mix16(data, 48, 96) + _mix16(data, length - 64, 112) + acc += _mix16(data, 32, 64) + _mix16(data, length - 48, 80) + acc += _mix16(data, 16, 32) + _mix16(data, length - 32, 48) + acc += _mix16(data, 0, 0) + _mix16(data, length - 16, 16) + return _xxh3_avalanche(acc & _MASK64) + + +def _xxh3_64_129_to_240(data: bytes) -> int: + length = len(data) + acc = length * _PRIME64_1 + for round_ in range(8): + acc += _mix16(data, 16 * round_, 16 * round_) + acc = _xxh3_avalanche(acc & _MASK64) + for round_ in range(8, length // 16): + acc += _mix16(data, 16 * round_, 16 * (round_ - 8) + 3) + acc += _mix16(data, length - 16, 136 - 17) + return _xxh3_avalanche(acc & _MASK64) + + +class XxHash3(_Xxh3Base): + """XXH3-64 with a seed of zero and the default secret.""" + + __slots__ = () + + def _digest(self) -> bytes: + if self._length <= _MIDSIZE_MAX: + value = _xxh3_64_short(self._buffer) + else: + value = _merge_accs( + self._long_acc(), 11, (self._length * _PRIME64_1) & _MASK64 + ) + return value.to_bytes(8, "big") + + +# -------------------------------------------------------------------------------------- +# XXH3-128 +# -------------------------------------------------------------------------------------- + + +def _xxh3_128_short(data: bytes) -> tuple[int, int]: + length = len(data) + if length > 128: + return _xxh3_128_129_to_240(data) + if length > 16: + return _xxh3_128_17_to_128(data) + if length > 8: + return _xxh3_128_9_to_16(data) + if length >= 4: + combined = _read32(data, 0) + (_read32(data, length - 4) << 32) + keyed = combined ^ (_read64(_SECRET, 16) ^ _read64(_SECRET, 24)) + product = keyed * ((_PRIME64_1 + (length << 2)) & _MASK64) + low = product & _MASK64 + high = ((product >> 64) + (low << 1)) & _MASK64 + low ^= high >> 3 + low ^= low >> 35 + low = (low * _PRIME_MX2) & _MASK64 + low ^= low >> 28 + return low, _xxh3_avalanche(high) + if length: + combined_low = ( + (data[0] << 16) | (data[length >> 1] << 24) | data[-1] | (length << 8) + ) + swapped = _swap32(combined_low) + combined_high = ((swapped << 13) | (swapped >> 19)) & 0xFFFFFFFF + low = combined_low ^ (_read32(_SECRET, 0) ^ _read32(_SECRET, 4)) + high = combined_high ^ (_read32(_SECRET, 8) ^ _read32(_SECRET, 12)) + return _xxh64_avalanche(low), _xxh64_avalanche(high) + return ( + _xxh64_avalanche(_read64(_SECRET, 64) ^ _read64(_SECRET, 72)), + _xxh64_avalanche(_read64(_SECRET, 80) ^ _read64(_SECRET, 88)), + ) + + +def _xxh3_128_9_to_16(data: bytes) -> tuple[int, int]: + length = len(data) + first = _read64(data, 0) + last = _read64(data, length - 8) + product = ( + first ^ last ^ (_read64(_SECRET, 32) ^ _read64(_SECRET, 40)) + ) * _PRIME64_1 + low = (product + ((length - 1) << 54)) & _MASK64 + high = product >> 64 + last ^= _read64(_SECRET, 48) ^ _read64(_SECRET, 56) + high = (high + last + (last & 0xFFFFFFFF) * (_PRIME32_2 - 1)) & _MASK64 + low ^= _swap64(high) + product = low * _PRIME64_2 + final_low = product & _MASK64 + final_high = ((product >> 64) + high * _PRIME64_2) & _MASK64 + return _xxh3_avalanche(final_low), _xxh3_avalanche(final_high) + + +def _mix32( + acc: tuple[int, int], + data: bytes, + first: int, + second: int, + secret_offset: int, +) -> tuple[int, int]: + low, high = acc + low += _mix16(data, first, secret_offset) + low ^= (_read64(data, second) + _read64(data, second + 8)) & _MASK64 + high += _mix16(data, second, secret_offset + 16) + high ^= (_read64(data, first) + _read64(data, first + 8)) & _MASK64 + return low & _MASK64, high & _MASK64 + + +def _finish_128(acc: tuple[int, int], length: int) -> tuple[int, int]: + low, high = acc + final_low = (low + high) & _MASK64 + final_high = (low * _PRIME64_1 + high * _PRIME64_4 + length * _PRIME64_2) & _MASK64 + return _xxh3_avalanche(final_low), (-_xxh3_avalanche(final_high)) & _MASK64 + + +def _xxh3_128_17_to_128(data: bytes) -> tuple[int, int]: + length = len(data) + acc = ((length * _PRIME64_1) & _MASK64, 0) + if length > 32: + if length > 64: + if length > 96: + acc = _mix32(acc, data, 48, length - 64, 96) + acc = _mix32(acc, data, 32, length - 48, 64) + acc = _mix32(acc, data, 16, length - 32, 32) + acc = _mix32(acc, data, 0, length - 16, 0) + return _finish_128(acc, length) + + +def _xxh3_128_129_to_240(data: bytes) -> tuple[int, int]: + length = len(data) + acc = ((length * _PRIME64_1) & _MASK64, 0) + for offset in range(0, 128, 32): + acc = _mix32(acc, data, offset, offset + 16, offset) + acc = (_xxh3_avalanche(acc[0]), _xxh3_avalanche(acc[1])) + for offset in range(128, length - 31, 32): + acc = _mix32(acc, data, offset, offset + 16, 3 + offset - 128) + acc = _mix32(acc, data, length - 16, length - 32, 136 - 17 - 16) + return _finish_128(acc, length) + + +class XxHash128(_Xxh3Base): + """XXH3-128 with a seed of zero and the default secret.""" + + __slots__ = () + + def _digest(self) -> bytes: + if self._length <= _MIDSIZE_MAX: + low, high = _xxh3_128_short(self._buffer) + else: + acc = self._long_acc() + low = _merge_accs(acc, 11, (self._length * _PRIME64_1) & _MASK64) + high = _merge_accs( + acc, + _SECRET_SIZE - _STRIPE_LEN - 11, + ~(self._length * _PRIME64_2) & _MASK64, + ) + return ((high << 64) | low).to_bytes(16, "big") diff --git a/packages/smithy-checksums/tests/unit/test_checksums.py b/packages/smithy-checksums/tests/unit/test_checksums.py index f29d5bab0..33a772305 100644 --- a/packages/smithy-checksums/tests/unit/test_checksums.py +++ b/packages/smithy-checksums/tests/unit/test_checksums.py @@ -12,6 +12,9 @@ CRC32C, CRC64NVME, Checksum, + XXHash3, + XXHash64, + XXHash128, new, ) @@ -21,6 +24,9 @@ "crc32": CRC32, "crc32c": CRC32C, "crc64nvme": CRC64NVME, + "xxhash64": XXHash64, + "xxhash3": XXHash3, + "xxhash128": XXHash128, } @@ -93,6 +99,9 @@ def compute(_: int) -> str: ("crc32", CRC32), ("CRC32C", CRC32C), ("CRC64NVME", CRC64NVME), + ("XXHASH64", XXHash64), + ("xxhash3", XXHash3), + ("XxHash128", XXHash128), ], ) def test_new_returns_checksum_classes(name: str, expected: type[Checksum]) -> None: diff --git a/packages/smithy-checksums/tests/unit/test_implementations.py b/packages/smithy-checksums/tests/unit/test_implementations.py index 063047753..dd9550d1d 100644 --- a/packages/smithy-checksums/tests/unit/test_implementations.py +++ b/packages/smithy-checksums/tests/unit/test_implementations.py @@ -13,7 +13,7 @@ from types import ModuleType import pytest -from smithy_checksums._private import crc +from smithy_checksums._private import crc, xxhash from smithy_checksums._private.backend import ChecksumImpl from .vectors import ALGORITHMS, KNOWN_ANSWERS, PATTERN_DIGESTS, pattern @@ -24,6 +24,9 @@ "crc32": crc.Crc32, "crc32c": crc.Crc32C, "crc64nvme": crc.Crc64Nvme, + "xxhash64": xxhash.XxHash64, + "xxhash3": xxhash.XxHash3, + "xxhash128": xxhash.XxHash128, } @@ -43,6 +46,9 @@ def _load_native() -> ModuleType | None: "crc32": _NATIVE_MODULE.Crc32, "crc32c": _NATIVE_MODULE.Crc32C, "crc64nvme": _NATIVE_MODULE.Crc64Nvme, + "xxhash64": _NATIVE_MODULE.XxHash64, + "xxhash3": _NATIVE_MODULE.XxHash3, + "xxhash128": _NATIVE_MODULE.XxHash128, } ) requires_native = pytest.mark.skipif( @@ -176,6 +182,9 @@ def test_matches_crt(impls: dict[str, Factory], algorithm: str) -> None: "crc32": lambda data: crt.crc32(data).to_bytes(4, "big"), "crc32c": lambda data: crt.crc32c(data).to_bytes(4, "big"), "crc64nvme": lambda data: crt.crc64nvme(data).to_bytes(8, "big"), + "xxhash64": crt.XXHash.compute_xxhash64, + "xxhash3": crt.XXHash.compute_xxhash3_64, + "xxhash128": crt.XXHash.compute_xxhash3_128, } data = os.urandom(3000) for length in [*range(0, 300, 7), 1023, 1024, 1025, 3000]: diff --git a/packages/smithy-checksums/tests/unit/vectors.py b/packages/smithy-checksums/tests/unit/vectors.py index a02f018aa..f887f0e79 100644 --- a/packages/smithy-checksums/tests/unit/vectors.py +++ b/packages/smithy-checksums/tests/unit/vectors.py @@ -2,12 +2,87 @@ # SPDX-License-Identifier: Apache-2.0 """Expected digests shared by the checksum tests.""" -ALGORITHMS = ("crc32", "crc32c", "crc64nvme") +ALGORITHMS = ("crc32", "crc32c", "crc64nvme", "xxhash64", "xxhash3", "xxhash128") + + +def xxhash_sanity_input(length: int) -> bytes: + """Returns the first ``length`` bytes of the buffer xxHash's sanity tests hash.""" + data = bytearray() + generator = 0x9E3779B1 + for _ in range(length): + data.append(generator >> 56) + generator = (generator * 0x9E3779B185EBCA8D) & 0xFFFFFFFFFFFFFFFF + return bytes(data) + + +# Digests of xxhash_sanity_input(length) with a seed of zero, from xxHash's +# tests/sanity_test_vectors.h at commit c629a3113f8bf6a2e4dbab151741dbaa776af8fa. +XXHASH_SANITY_DIGESTS: dict[str, dict[int, str]] = { + "xxhash64": { + 1: "e934a84adb052768", + 3: "ff7e1959cb50794a", + 4: "9136a0dca57457ee", + 8: "cdbcf538e71d1348", + 9: "554b1ae991eda6b6", + 16: "98c90b57fdfcb55c", + 17: "0d39a2d051a30c2c", + 128: "90ca021457d96dc5", + 129: "41c280132d697aba", + 240: "b81838d483baee53", + 241: "95d76c8b4d8fc4d6", + 1024: "4775bf7cace4d177", + 1025: "847fa6006d7c2ac0", + 2048: "5940f2752bc04387", + 4160: "eee6a4e2ac952a5e", + }, + "xxhash3": { + 1: "c44bdff4074eecdb", + 3: "54247382a8d6b94d", + 4: "e5dc74bc51848a51", + 8: "24ccc9acaa9f65e4", + 9: "14d5001c15dd3f2b", + 16: "981b17d36c7498c9", + 17: "796f5acd3a60f862", + 128: "fcff24126754d861", + 129: "98f1b0a679a2ca29", + 240: "81c3c2b67f568ccf", + 241: "c5a639ecd2030e5e", + 1024: "dd85c9b5c1109c5c", + 1025: "d870c0fa13211c6a", + 2048: "dd59e2c3a5f038e0", + 4160: "4f323b15321e94e1", + }, + "xxhash128": { + 1: "a6cd5e9392000f6ac44bdff4074eecdb", + 3: "20efc49ff02422ea54247382a8d6b94d", + 4: "970d585ac632bf8e2e7d8d6876a39fe9", + 8: "47a7f080d82bb45664c69cab4bb21dc5", + 9: "564ef6078950d457ed7ccbc501eb7501", + 16: "c68c368ecf8a9c05562980258a998629", + 17: "955fa78643ed3669abbc12d11973d7db", + 128: "39992220e045260aebb15e34a7fb5ab1", + 129: "03815fc91f1b30b686c9e3bc8f0a3b5c", + 240: "aa4202daa2769dc85c9aae94c8ebe5a0", + 241: "99a80ecf0ecfc647c5a639ecd2030e5e", + 1024: "0d30d24071c64c57dd85c9b5c1109c5c", + 1025: "fd3ee4fe7f2954c6d870c0fa13211c6a", + 2048: "f736557fd47073a5dd59e2c3a5f038e0", + 4160: "67140711c1e3e3354f323b15321e94e1", + }, +} + + +def _xxhash_sanity_answers(algorithm: str) -> dict[bytes, str]: + return { + xxhash_sanity_input(length): digest + for length, digest in XXHASH_SANITY_DIGESTS[algorithm].items() + } + # Digests from independent sources: the standard check value of each CRC (the # digest of "123456789"), the CRC32C test vectors from RFC 3720, the CRC64NVME test -# cases from the NVMe NVM Command Set specification, and "Hello world" digests that -# match the ones the CRT produces. +# cases from the NVMe NVM Command Set specification, xxHash's own sanity-test vectors, +# and "Hello world" digests that match the ones the CRT produces. KNOWN_ANSWERS: dict[str, dict[bytes, str]] = { "crc32": {b"123456789": "cbf43926", b"Hello world": "8bd69e52"}, "crc32c": { @@ -29,6 +104,21 @@ bytes(i % 256 for i in range(4096)): "3e729f5f6750449c", bytes(255 - i % 256 for i in range(4096)): "9a2df64b8e9e517e", }, + "xxhash64": { + b"": "ef46db3751d8e999", + b"Hello world": "c500b0c912b376d8", + **_xxhash_sanity_answers("xxhash64"), + }, + "xxhash3": { + b"": "2d06800538d394c2", + b"Hello world": "b6acb9d84a38ff74", + **_xxhash_sanity_answers("xxhash3"), + }, + "xxhash128": { + b"": "99aa06d3014798d86001c324468d497f", + b"Hello world": "7351f89812f97382b91d05b31e04dd7f", + **_xxhash_sanity_answers("xxhash128"), + }, } @@ -37,8 +127,8 @@ def pattern(length: int) -> bytes: return bytes(i % 251 for i in range(length)) -# Digests of pattern(length), from inputs shorter than a word to several -# kilobytes. +# Digests of pattern(length). The lengths cover each size class of XXH3 and the +# boundaries of its 64-byte stripes and 1024-byte blocks. PATTERN_DIGESTS: dict[str, dict[int, str]] = { "crc32": { 0: "00000000", @@ -91,4 +181,55 @@ def pattern(length: int) -> bytes: 1025: "034aba81017791d2", 16385: "9c0531959bc1eacf", }, + "xxhash64": { + 0: "ef46db3751d8e999", + 1: "e934a84adb052768", + 3: "e5c7bb4533bc65dd", + 4: "ffced8604453cc1e", + 8: "884a173614b81b8d", + 9: "67d85784a7c78c5b", + 16: "44b6ef2fb84169f7", + 17: "5603e60c527599b6", + 128: "7a7fe14647b9ab92", + 129: "0ba25dfd6e891fcf", + 240: "012947f0da6a27b1", + 241: "8d643f23bf2808e1", + 1024: "138e26c65048ce29", + 1025: "cfd73aedd2d6a39d", + 16385: "70c0f3c188d8aa7f", + }, + "xxhash3": { + 0: "2d06800538d394c2", + 1: "c44bdff4074eecdb", + 3: "5f4299fc161c9cbb", + 4: "60dab036a58211f2", + 8: "3a1c2d7c85af88f8", + 9: "e9612598145bb9dc", + 16: "8355e3a6f61770db", + 17: "9ef341a99de37328", + 128: "85c6174c7ff4c46b", + 129: "ec7642b431ba3e5a", + 240: "375a384d957fe865", + 241: "02e8cd95421c6d02", + 1024: "e5d78bafa45b2aa5", + 1025: "e95c42288f28186e", + 16385: "fea38d9173737a4b", + }, + "xxhash128": { + 0: "99aa06d3014798d86001c324468d497f", + 1: "a6cd5e9392000f6ac44bdff4074eecdb", + 3: "e3b55f57945a17cf5f4299fc161c9cbb", + 4: "eb70bf5fc779e9e6a6111d53e80a3db5", + 8: "e1e4432a62217fe4cfd50c61c8bb98c1", + 9: "16c769d83e4aebce907931979dca3746", + 16: "72950631827607e2842812cc870dcae2", + 17: "685bc458b37d057fc06e233df7729217", + 128: "14792fc3af88dc6c05321a0b64d67b41", + 129: "dd5e74ac6b45f54ebc30b63382b09a3b", + 240: "65b5be86da5540e7c92b68e16f83bbb6", + 241: "1da1cb61bcb8a2a102e8cd95421c6d02", + 1024: "d0ac1f7b93bf57b9e5d78bafa45b2aa5", + 1025: "2882ebca04ec915ce95c42288f28186e", + 16385: "7f1012a369696b6dfea38d9173737a4b", + }, } diff --git a/scripts/api-snapshot/api.json b/scripts/api-snapshot/api.json index 37809ed55..7f690ce0c 100644 --- a/scripts/api-snapshot/api.json +++ b/scripts/api-snapshot/api.json @@ -611,6 +611,9 @@ "CRC64NVME": "class", "Checksum": "protocol", "HAS_NATIVE_EXTENSION": "bool", + "XXHash128": "class", + "XXHash3": "class", + "XXHash64": "class", "new": "function" }, "smithy_core": {