xxhash¶
Digest bytes, values, handles, and Arrow rows with XXH32, XXH64, XXH3-64, and XXH3-128.
Not a cryptographic hash, and not Iceberg's bucket transform
A digest detects accidental change - a truncated upload, a stale cache entry, a duplicated row - and nothing more. Never use one as an integrity check against an adversary who chooses the input, and never as a password or signature primitive.
Iceberg's bucket[N] transform is pinned by its specification to murmur3 x86_32. A
partition computed with xxHash would place rows in the wrong buckets and no other
reader would find them; iceberg never calls this module for partitioning.
One-shot digests¶
use yggdryl::xxhash;
assert_eq!(xxhash::xxh32(b"abc"), 0x32d1_53ff);
assert_eq!(xxhash::xxh64(b"abc"), 0x44bc_2cf5_ad77_0999);
assert_eq!(xxhash::xxh3(b"abc"), 0x78af_5f94_892f_3950);
assert_eq!(
xxhash::xxh128(b"abc"),
0x06b0_5ab6_733a_6185_78af_5f94_892f_3950,
);
// The input comes first and the seed second, here and everywhere.
assert_ne!(xxhash::xxh64_with_seed(b"abc", 42), xxhash::xxh64(b"abc"));
from yggdryl import xxhash
assert xxhash.xxh32(b"abc") == 0x32D153FF
assert xxhash.xxh64(b"abc") == 0x44BC2CF5AD770999
assert xxhash.xxh3(b"abc") == 0x78AF5F94892F3950
assert xxhash.xxh128(b"abc") == 0x06B05AB6733A618578AF5F94892F3950
# bytes, bytearray, memoryview, any buffer, or a str as its UTF-8.
assert xxhash.xxh3("abc") == xxhash.xxh3(bytearray(b"abc"))
assert xxhash.xxh64(b"abc", seed=42) != xxhash.xxh64(b"abc")
const assert = require('node:assert/strict')
const { xxhash } = require('yggdryl')
const payload = Buffer.from('abc')
// XXH32 answers a number - 32 bits always fit one exactly - and the wider
// algorithms answer bigints.
assert.equal(xxhash.xxh32(payload), 0x32d153ff)
assert.equal(xxhash.xxh64(payload), 0x44bc2cf5ad770999n)
assert.equal(xxhash.xxh3(payload), 0x78af5f94892f3950n)
assert.equal(xxhash.xxh128(payload), 0x06b05ab6733a618578af5f94892f3950n)
// Buffer, Uint8Array, ArrayBuffer, or a string as its UTF-8.
assert.equal(xxhash.xxh3('abc'), xxhash.xxh3(new Uint8Array(payload)))
assert.notEqual(xxhash.xxh64(payload, { seed: 42n }), xxhash.xxh64(payload))
The four functions answer their native widths with nothing wrapped around the number.
Digest is for a caller who wants the algorithm travelling with the value instead, and
DigestAlgorithm is the runtime dispatcher when the algorithm is a value rather than a
call - the same relationship Codec has to gzip.
Digest values¶
use yggdryl::{Digest, DigestAlgorithm};
let digest = DigestAlgorithm::Xxh3.digest(b"abc");
assert_eq!(digest.as_u64(), Some(0x78af_5f94_892f_3950));
assert_eq!(digest.to_string(), "xxh3-64:78af5f94892f3950");
assert_eq!(Digest::from_str(&digest.to_string())?, digest);
assert_eq!(digest.into_bytes().len(), 8);
// Two algorithms are never equal, whatever their payloads: `xxh64` and
// `xxh3-64` are both 64 bits wide and answer different values.
assert_ne!(
Digest::new(DigestAlgorithm::Xxh64, 7),
Digest::new(DigestAlgorithm::Xxh3, 7),
);
from yggdryl import xxhash
digest = xxhash.digest(b"abc", "xxh3-64")
assert int(digest) == 0x78AF5F94892F3950
assert str(digest) == "xxh3-64:78af5f94892f3950"
assert xxhash.Digest(str(digest)) == digest
assert len(bytes(digest)) == digest.width == 8
assert xxhash.Digest.from_int("xxh64", 7) != xxhash.Digest.from_int("xxh3-64", 7)
const assert = require('node:assert/strict')
const { xxhash } = require('yggdryl')
const digest = xxhash.digest(Buffer.from('abc'), 'xxh3-64')
assert.equal(digest.value(), 0x78af5f94892f3950n)
assert.equal(digest.toString(), 'xxh3-64:78af5f94892f3950')
assert.ok(xxhash.Digest.from(digest.toString()).equals(digest))
assert.equal(digest.bytes().length, digest.width)
const wide = xxhash.digest(Buffer.from('abc'), 'xxh3-128')
assert.ok(!wide.equals(digest))
The rendering carries the algorithm because the value does. Without it the two 64-bit
algorithms would share one spelling, and parsing could not be the exact inverse of
rendering. into_bytes is the canonical big-endian representation the reference calls
XXH*_canonicalFromHash, so two machines of different endianness store one digest as one
sequence of bytes.
Streaming states¶
use yggdryl::xxhash::{Xxh3, xxh3};
let payload = b"symbol,price\nAAPL,187.23\n";
for split in [1, 7, payload.len()] {
let mut state = Xxh3::new();
for chunk in payload.chunks(split) {
state.write_bytes(chunk);
}
// The split never changes the answer.
assert_eq!(state.as_u64(), xxh3(payload));
}
// Answering does not consume the state, so a running digest can be read at
// every commit boundary rather than only at the end.
let mut state = Xxh3::new();
state.write_bytes(b"AAPL");
assert_eq!(state.as_u64(), state.as_u64());
state.write_bytes(b",187.23");
assert_eq!(state.as_u64(), xxh3(b"AAPL,187.23"));
from yggdryl import xxhash
payload = b"symbol,price\nAAPL,187.23\n"
for split in (1, 7, len(payload)):
state = xxhash.Xxh3()
for index in range(0, len(payload), split):
state.write_bytes(payload[index : index + split])
assert int(state.as_digest()) == xxhash.xxh3(payload)
state = xxhash.Xxh3()
state.write_bytes(b"AAPL")
assert state.as_digest() == state.as_digest()
state.write_bytes(b",187.23")
assert int(state.as_digest()) == xxhash.xxh3(b"AAPL,187.23")
const assert = require('node:assert/strict')
const { xxhash } = require('yggdryl')
const payload = Buffer.from('symbol,price\nAAPL,187.23\n')
for (const split of [1, 7, payload.length]) {
const state = new xxhash.Xxh3()
for (let index = 0; index < payload.length; index += split) {
state.writeBytes(payload.subarray(index, index + split))
}
assert.equal(state.asDigest().value(), xxhash.xxh3(payload))
}
const state = new xxhash.Xxh3()
state.writeBytes(Buffer.from('AAPL'))
assert.ok(state.asDigest().equals(state.asDigest()))
state.writeBytes(Buffer.from(',187.23'))
assert.equal(state.asDigest().value(), xxhash.xxh3(Buffer.from('AAPL,187.23')))
Chunk invariance is the property everything else here rests on. A message spliced from two spans - a record with its row header removed from the middle of a line - hashes the same as the equivalent joined string, without ever building the join, and an empty chunk contributes nothing wherever it sits. A hash that depended on where the boundary fell would be a silent correctness bug.
clear() returns a state to the seed and secret it was constructed with, not to an
unseeded one. In Rust each state is also a std::hash::Hasher and its own BuildHasher, so
it drops into a HashMap carrying its seed; Hasher::finish on Xxh128 answers the low
64 bits because the trait's return type cannot carry more, and as_u128 is the full value.
Seeds and secrets¶
XXH32 and XXH64 take a seed and never a secret. Only the XXH3 pair is secretable, and
DigestAlgorithm::is_secretable is what a caller asks first.
XXH3 consults a custom secret only for inputs longer than 240 bytes. At or below that length the algorithm uses its derived secret and the seed, which is the protocol's own rule for the seed-and-secret family and what keeps a one-shot and a streaming state answering one value for the same bytes. A short secret is rejected by length whatever the payload, so a secret is never silently the wrong one - but a caller hashing short values with a secret is hashing them with the default secret, by design. The examples below use a payload past the cutoff for that reason.
use yggdryl::xxhash::{self, SECRET_MINIMUM_LENGTH, Xxh3};
use yggdryl::{DigestAlgorithm, Error};
assert!(!DigestAlgorithm::Xxh64.is_secretable());
assert!(DigestAlgorithm::Xxh3.is_secretable());
let secret = vec![0x5a_u8; SECRET_MINIMUM_LENGTH];
let payload = vec![0x11_u8; 241];
assert_ne!(
xxhash::xxh3_with_secret(&payload, &secret)?,
xxhash::xxh3(&payload),
);
// At or below the cutoff the secret is not consulted at all.
assert_eq!(xxhash::xxh3_with_secret(b"AAPL", &secret)?, xxhash::xxh3(b"AAPL"));
// A short secret is refused by length, whatever the payload: the reference
// only consults a secret past its 240-byte cutoff, and a secret that is
// sometimes used is worse than one that is refused.
let short = vec![0x5a_u8; SECRET_MINIMUM_LENGTH - 1];
let error = Xxh3::from_secret(&short).unwrap_err();
assert!(matches!(error, Error::InvalidSecret { actual: 135, .. }));
import pytest
from yggdryl import xxhash
secret = bytes(xxhash.SECRET_MINIMUM_LENGTH)
payload = bytes(241)
assert xxhash.xxh3(payload, secret=secret) != xxhash.xxh3(payload)
# At or below the cutoff the secret is not consulted at all.
assert xxhash.xxh3(b"AAPL", secret=secret) == xxhash.xxh3(b"AAPL")
with pytest.raises(ValueError, match="at least 136 bytes, got 135"):
xxhash.Xxh3(secret=bytes(xxhash.SECRET_MINIMUM_LENGTH - 1))
const assert = require('node:assert/strict')
const { xxhash } = require('yggdryl')
const payload = Buffer.alloc(241)
const secret = new Uint8Array(xxhash.SECRET_MINIMUM_LENGTH)
assert.notEqual(xxhash.xxh3(payload, { secret }), xxhash.xxh3(payload))
// At or below the cutoff the secret is not consulted at all.
const brief = Buffer.from('AAPL')
assert.equal(xxhash.xxh3(brief, { secret }), xxhash.xxh3(brief))
const truncated = new Uint8Array(xxhash.SECRET_MINIMUM_LENGTH - 1)
assert.throws(
() => xxhash.xxh3(payload, { secret: truncated }),
/at least 136 bytes, got 135/,
)
Handle digests¶
use yggdryl::IOBase;
use yggdryl::holder::Buffer;
use yggdryl::{DigestAlgorithm, xxhash};
let mut handle = Buffer::new();
handle.write_all_bytes(b"symbol,price\nAAPL,187.23\n")?;
assert_eq!(
handle.read_digest(DigestAlgorithm::Xxh3)?,
DigestAlgorithm::Xxh3.digest(&handle.read_all_bytes()?),
);
assert_eq!(
handle.read_range_digest(0, 6, DigestAlgorithm::Xxh3)?,
DigestAlgorithm::Xxh3.digest(b"symbol"),
);
// A resource that does not exist digests as no bytes, per the laziness
// contract - absence is emptiness, not a third answer to branch on.
assert_eq!(
Buffer::new().read_digest(DigestAlgorithm::Xxh3)?.as_u64(),
Some(xxhash::xxh3(b"")),
);
import tempfile
from pathlib import Path
from yggdryl import IOBase, xxhash
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "trades.csv"
path.write_bytes(b"symbol,price\nAAPL,187.23\n")
handle = IOBase(path)
assert handle.read_digest("xxh3-64") == xxhash.digest(handle.read_bytes(), "xxh3-64")
assert handle.read_range_digest(0, 6) == xxhash.digest(b"symbol", "xxh3-64")
missing = IOBase(Path(root) / "never-written.csv")
assert missing.read_digest() == xxhash.digest(b"", "xxh3-64")
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { IOBase, xxhash } = require('yggdryl')
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'yggdryl-digest-'))
try {
const file = path.join(root, 'trades.csv')
const payload = Buffer.from('symbol,price\nAAPL,187.23\n')
fs.writeFileSync(file, payload)
const handle = new IOBase(file)
assert.ok(handle.readDigest('xxh3-64').equals(xxhash.digest(payload, 'xxh3-64')))
assert.ok(handle.readRangeDigest(0, 6).equals(xxhash.digest(Buffer.from('symbol'), 'xxh3-64')))
const missing = new IOBase(path.join(root, 'never-written.csv'))
assert.ok(missing.readDigest().equals(xxhash.digest(Buffer.alloc(0), 'xxh3-64')))
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
read_digest streams through pstream_bytes and retains one bounded chunk, so
memory is flat in the object's size: a 64 GiB file costs one window rather than a copy.
Nothing calls read_all_bytes. Both methods are derived, so every backend and every wrapper
inherits them unchanged - which is what makes the two questions a compressed handle can be
asked stay distinct:
use yggdryl::coding::gzip::Gzip;
use yggdryl::IOBase;
use yggdryl::holder::Buffer;
use yggdryl::{DigestAlgorithm, xxhash};
let plain = b"symbol,price\nAAPL,187.23\n";
let mut handle = Gzip::new(Buffer::new());
handle.write_all_bytes(plain)?;
handle.flush()?;
// The wrapper answers for the bytes it presents.
assert_eq!(
handle.read_digest(DigestAlgorithm::Xxh3)?.as_u64(),
Some(xxhash::xxh3(plain)),
);
// The handle underneath answers for the bytes it holds.
let compressed = handle.handle().read_all_bytes()?;
assert_eq!(
handle.handle().read_digest(DigestAlgorithm::Xxh3)?.as_u64(),
Some(xxhash::xxh3(&compressed)),
);
assert_ne!(xxhash::xxh3(plain), xxhash::xxh3(&compressed));
A container holds no bytes of its own, so it is a typed Error::NotAtomic naming the kind
rather than an answer. Which files a folder digest would cover, in what order, is a
convention no format states; folder and recursive digests are deliberately absent.
Reading and writing through a digest¶
Rust only
DigestReader and DigestWriter are built on Read and Write, which neither binding
has a native spelling for - the same reason gzip's reader/writer pair is
Rust only.
use std::io::{Read, Write};
use yggdryl::{DigestAlgorithm, xxhash};
// Hash a payload that is already being moved, in the pass that was already
// happening, rather than reading it a second time.
let mut source = xxhash::reader(b"AAPL,187.23".as_slice(), DigestAlgorithm::Xxh3);
let mut moved = Vec::new();
source.read_to_end(&mut moved)?;
assert_eq!(moved, b"AAPL,187.23");
assert_eq!(source.as_digest(), DigestAlgorithm::Xxh3.digest(&moved));
let mut target = xxhash::writer(Vec::new(), DigestAlgorithm::Xxh64);
target.write_all(b"AAPL,187.23")?;
assert_eq!(target.as_digest(), DigestAlgorithm::Xxh64.digest(b"AAPL,187.23"));
assert_eq!(target.into_inner(), b"AAPL,187.23");
Hashed handles¶
Rust only
Hashed<H> wraps an IOBase, which the bindings reach as IOBase rather than as a
generic type parameter.
use yggdryl::IOBase;
use yggdryl::holder::Buffer;
use yggdryl::xxhash::Hashed;
use yggdryl::DigestAlgorithm;
let mut handle = Hashed::new(Buffer::new(), DigestAlgorithm::Xxh3);
handle.write_all_bytes(b"symbol,price\n")?;
handle.append_bytes(b"AAPL,187.23\n")?;
handle.flush()?;
// Answered from the running state; the bytes are never read back.
assert_eq!(
handle.read_digest(DigestAlgorithm::Xxh3)?,
DigestAlgorithm::Xxh3.digest(b"symbol,price\nAAPL,187.23\n"),
);
// A positional write the running state cannot follow makes it stale, which is
// not an error and not silent corruption: the next digest re-streams the
// handle and re-arms, and the answer is identical either way.
handle.pwrite_all(7, b"PRICE")?;
assert_eq!(
handle.read_digest(DigestAlgorithm::Xxh3)?,
DigestAlgorithm::Xxh3.digest(&handle.read_all_bytes()?),
);
The running state covers writes that are strictly sequential from offset 0 - a whole write,
repeated appends, a streamed record write - which is the case a lake actually writes. It is
consulted only when it covers the whole value, which is what makes pending writes count only
after flush: a handle that stages writes until publication is streamed until its size
catches up.
The canonical value feed¶
Every Scalar has one canonical byte representation, and it is what a digest, a row key,
and stable_hash all read. Two spellings answer two different questions.
as_value_bytes is the payload alone - no tag, no length - so hashing a string equals
hashing its UTF-8 and agrees with any other xxHash implementation on the same bytes. It
borrows wherever the value already holds bytes and never allocates.
write_bytes is the total, prefix-free feed over every variant.
The borrowed view is Rust only
Both bindings reach the feed - Scalar.digest and a state's write_scalar read exactly
these bytes. as_value_bytes is the borrowed view of a payload the value already holds,
which has no spelling that survives the boundary without the copy it exists to avoid.
use yggdryl::{DigestAlgorithm, Scalar, xxhash};
let symbol = Scalar::from("AAPL");
assert_eq!(&*symbol.as_value_bytes().unwrap(), b"AAPL");
assert_eq!(
xxhash::xxh3(&symbol.as_value_bytes().unwrap()),
xxhash::xxh3(b"AAPL"),
);
// Equal values answer one digest, across widths.
assert_eq!(Scalar::from(1_i8), Scalar::from(1_i64));
assert_eq!(
Scalar::from(1_i8).digest(DigestAlgorithm::Xxh3),
Scalar::from(1_i64).digest(DigestAlgorithm::Xxh3),
);
// Values that differ stay apart, across variant boundaries.
assert_ne!(
Scalar::from("1").digest(DigestAlgorithm::Xxh3),
Scalar::from(0x31_u8).digest(DigestAlgorithm::Xxh3),
);
// A null and an empty string are not the same absence.
assert_ne!(
Scalar::Null.digest(DigestAlgorithm::Xxh3),
Scalar::from("").digest(DigestAlgorithm::Xxh3),
);
from yggdryl import Scalar, xxhash
assert Scalar.from_py("AAPL").digest() == Scalar.from_py("AAPL").digest("xxh3-64")
assert int(Scalar.from_py("AAPL").digest()) == Scalar.from_py("AAPL").stable_hash()
# Equal values answer one digest, across widths.
assert Scalar.decimal(100, 2).digest() == Scalar.decimal(1, 0).digest()
assert Scalar.float(1.5, 32).digest() == Scalar.float(1.5, 64).digest()
# Values that differ stay apart, across variant boundaries.
assert Scalar.from_py("1").digest() != Scalar.from_py(b"1").digest()
assert Scalar.from_py(None).digest() != Scalar.from_py("").digest()
state = xxhash.Xxh3()
state.write_scalar(Scalar.from_py("AAPL"))
assert state.as_digest() == Scalar.from_py("AAPL").digest()
const assert = require('node:assert/strict')
const { Scalar, xxhash } = require('yggdryl')
const symbol = Scalar.fromJs('AAPL')
assert.ok(symbol.digest().equals(symbol.digest('xxh3-64')))
assert.equal(symbol.digest().value(), symbol.stableHash())
// Equal values answer one digest, across widths.
assert.ok(Scalar.decimal(100n, 2).digest().equals(Scalar.decimal(1n, 0).digest()))
// Values that differ stay apart, across variant boundaries.
assert.ok(!Scalar.fromJs('1').digest().equals(Scalar.fromJs(Buffer.from('1')).digest()))
const state = new xxhash.Xxh3()
state.writeScalar(symbol)
assert.ok(state.asDigest().equals(symbol.digest()))
Encoding¶
Every value begins with one tag byte, a DataTypeId discriminant. That byte
is a wire contract: inserting a variant into DataTypeId anywhere but the end changes
stored digests, and a test pinning every value is what turns that into a failure rather
than a surprise. Every integer in the feed is little-endian, explicitly, so a digest does
not depend on the machine that computed it.
Scalar compares across widths - I8(1), I64(1) and U8(1) are one value, as are
F32(1.5) and F64(1.5), and D128(100, 2) and D256(1, 0) - so the feed writes each
family's canonical form rather than its storage width. A digest identifies the value, not
the box it came in.
| Variant | Tag | Feed after the tag |
|---|---|---|
Null |
null |
nothing |
Bool |
boolean |
0x00 or 0x01 |
I8..U128 |
uint128, or int128 when negative |
magnitude as u128 little-endian |
F16/F32/F64 |
float64 |
the common f64 reading's IEEE bits, little-endian |
D128/D256 |
decimal256 |
normalized coefficient as i256 little-endian, then scale as one signed byte |
String |
utf8 |
length u64 little-endian, then UTF-8 |
Enum |
dictionary |
length-prefixed enum identity, then the member ordinal |
Bytes |
binary |
length u64 little-endian, then the bytes |
Geospatial |
geometry |
length u64 little-endian, then the WKB |
Date32/Date64 |
date64 |
unit class byte, normalized count as i128 little-endian, length-prefixed timezone |
Time32/Time64 |
time64 |
as above |
DateTime64 |
timestamp |
as above |
Duration32/Duration64 |
duration64 |
as above |
Sequence |
list |
element count u64 little-endian, then each element's feed |
Mapping |
map |
entry count u64 little-endian, then each key feed and value feed in stored order |
Record |
struct |
entry count u64 little-endian, then per sorted entry a length-prefixed name and the value's feed |
as_value_bytes answers None for Null, Sequence, Mapping, and Record, which have
no payload without a framing. It keeps the storage width the feed collapses, and a decimal
answers its coefficient while a temporal answers its stored count - the scale, unit, and
zone beside them are the value's type rather than its payload.
Nesting is bounded by the shared structured-value limit, DataType::PARSE_RECURSION_LIMIT.
A subtree past it feeds one reserved 0xff in place of the subtree, so the walk stays total
and allocation-free for any caller input and never reaches a panic; values differing only
below that depth are indistinguishable, exactly as Scalar::dtype refuses to name them.
One hash contract¶
stable_hash is XXH3-64 over this feed, everywhere. There is no second hash family in the
tree and no second spelling of this one:
use yggdryl::{DigestAlgorithm, Scalar, text};
let value = Scalar::from("AAPL");
assert_eq!(
value.stable_hash(),
value.digest(DigestAlgorithm::Xxh3).as_u64().unwrap(),
);
// Equal values hash equally across widths, which is the invariant every
// binding relies on.
assert_eq!(
Scalar::from(1_i8).stable_hash(),
Scalar::from(1_i64).stable_hash()
);
let _ = text::Format::Json;
Values that carry text rather than a Scalar - Field, Uri, DataType, MimeType, the
Iceberg values - hash their canonical rendering through the same algorithm, so
Field::stable_hash and xxhash::xxh3 of that rendering are one number reached two
ways.
Filling digest holders¶
A digest holder is a field carrying digest:role=holder. Every resumable state fills all
holders in one Arrow RecordBatch under an authoritative non-null Struct root:
use std::sync::Arc;
use arrow_array::cast::AsArray as _;
use arrow_array::types::UInt64Type;
use arrow_array::{RecordBatch, StringArray};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema};
use yggdryl::xxhash::Xxh3;
use yggdryl::{DataType, DigestAlgorithm, Field, Scalar};
let symbol = Field::new("symbol", DataType::Utf8, false);
let mut holder = Field::new("row_digest", DataType::UInt64, false);
holder.as_digest_mut().set_holder()?;
holder.as_digest_mut().set_paths(["symbol"])?;
holder
.as_digest_mut()
.set_algorithm(DigestAlgorithm::Xxh3)?;
let root = DataType::from_fields([symbol, holder])?.required_field("row");
// The target root adds the missing holder in its declared position.
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![ArrowField::new(
"symbol",
ArrowDataType::Utf8,
false,
)])),
vec![Arc::new(StringArray::from(vec!["AAPL"]))],
)?;
let mut state = Xxh3::with_seed(7);
state.write_bytes(b"an unrelated running stream");
let running = state.as_u64();
let filled = state.fill_arrow_batch(&root, batch, false)?;
let mut expected = Xxh3::with_seed(7);
expected.write_scalar(&Scalar::from_sequence([Scalar::from("AAPL")]));
assert_eq!(
filled.column(1).as_primitive::<UInt64Type>().value(0),
expected.as_u64(),
);
assert_eq!(state.as_u64(), running, "filling does not consume the state");
digest:paths is a canonical JSON array of unique, non-empty paths stored on a holder, for
example ["id","line.price"]. Its order is the
hash-feed order. Paths are relative to the containing Struct: an exact whole field name wins,
then dots descend through Struct fields only. A list, map, union, or other value may be selected
whole but cannot be traversed. An absent key uses the containing Struct's component fallback;
[] deliberately hashes an empty sequence.
Nested Struct holders are filled deepest first. Selecting a nested Struct with one direct holder
feeds that holder's digest payload instead of hashing the Struct again. Signed integer holder
storage is first bit-cast back to the same-width unsigned payload, so int32/uint32 and
int64/uint64 schemas produce the same containing digest. With no holder the Struct feeds
normally; multiple direct holders are ambiguous and must be replaced by a path to the intended
nested holder. A holder cannot select itself or another holder in the same Struct.
Each visible row is framed as an ordered Scalar::Sequence and streamed through the state's
canonical value feed. With force=false, a cell equal to its holder Field's default is computed
and every non-default value is trusted and preserved. With force=true, every visible holder is
recomputed. Thus a nullable holder treats null as unfilled while preserving a present zero; a
required integer holder treats zero as unfilled. Children hidden below a null Struct are not read
or changed. An existing non-default value carries no proof that its declared algorithm, seed, or
secret produced it; set force when that provenance is not trusted.
digest:algorithm is the canonical algorithm token a holder explicitly requests: xxh32,
xxh64, xxh3-64, or xxh3-128. Without it, a receiver whose output width fits the holder is
used with its seed and secret. Otherwise the holder type selects the best fresh default: int32
or uint32 selects XXH32, int64 or uint64 selects XXH3-64, and
fixed_size_binary(16) selects XXH3-128. Signed holders store the identical output bits through
the explicit signed/unsigned field bit cast; a set high bit therefore appears as a negative integer,
without overflow or loss. An explicit algorithm must fit the same storage mapping. If it differs
from the receiver, its state is fresh and unseeded
because the receiver's configuration
belongs to a different algorithm. Python spells the operation
state.fill_arrow_batch(root, batch, force=True); JavaScript spells it
state.fillArrowBatch(root, batch, true) and copies the batch through Arrow IPC.
Arrow row digests¶
Rust only
Row and column digest arrays answer an ArrayRef. Both bindings reach Arrow through
their own runtime holders, so the column is built there rather than crossing.
use std::sync::Arc;
use arrow_array::cast::AsArray as _;
use arrow_array::types::UInt64Type;
use arrow_array::{Int64Array, RecordBatch, StringArray, UInt64Array};
use arrow_schema::Schema;
use yggdryl::{DataType, DigestAlgorithm, Field};
use yggdryl::xxhash::arrow::row_digests;
let mut symbol = Field::new("symbol", DataType::Utf8, false);
symbol.as_digest_mut().set_component()?;
let quantity = Field::new("quantity", DataType::Int64, false);
let mut stored = Field::new("row_digest", DataType::UInt64, false);
stored.as_digest_mut().set_holder()?;
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
symbol.into_arrow()?,
quantity.into_arrow()?,
stored.into_arrow()?,
])),
vec![
Arc::new(StringArray::from(vec!["AAPL", "MSFT", "AAPL"])),
Arc::new(Int64Array::from(vec![100, 250, 999])),
Arc::new(UInt64Array::from(vec![11, 22, 33])),
],
)?;
let digests = row_digests(&batch, DigestAlgorithm::Xxh3)?;
let digests = digests.as_primitive::<UInt64Type>();
// `symbol` is the explicit component. Differences in the unmarked quantity
// and the prior holder do not feed the digest.
assert_eq!(digests.value(0), digests.value(2));
assert_ne!(digests.value(0), digests.value(1));
The selected values remain an ordered Scalar::Sequence in schema order, including its element-count
framing. One or more fields carrying digest:role=component are the exact selection. If there is no
explicit component, every field except digest:role=holder is selected; an unmarked schema therefore
keeps the full-row behavior. A schema containing only holders hashes the empty sequence for every
row. Field names, roles, and other metadata choose the values but do not enter the byte feed.
That sequence-feed equality is the contract and the test on every datatype family, nulls, nested
structs, lists, maps, dictionaries, unions, and geospatial values. The
DigestField selection helpers expose the same effective
component set without hashing a batch.
row_digests always uses its algorithm argument and the direct role selection above. It does not
resolve holder-local digest:paths or digest:algorithm; those configure
fill_arrow_batch only.
column_digests is the single-column form: each answer is the cell's own value, with no row
framing around it. Nulls feed the null tag, so a null and an empty string never collide.
The column is UInt32 for XXH32, UInt64 for the two 64-bit algorithms, and
FixedSizeBinary(16) of canonical big-endian bytes for XXH3-128, which no Arrow integer is
wide enough to hold.
Benchmarks¶
rust/benchmarks/xxhash.rs, python/benchmarks/xxhash.py, and
node/benchmarks/xxhash.js measure the same protocol from the three sides. One containerized
x86_64 Linux run (Intel Xeon @ 2.10 GHz, 4 cores, 16 GiB; rustc 1.94.1 release with thin LTO;
CPython 3.11.15; Node 22.22.2), cargo bench --bench xxhash. Fixtures are built once, outside
every measured loop. The Arrow groups report rows per second for missing/default holders,
preserved populated holders, and forced recomputation; the JavaScript rows include its required IPC
copy.
Throughput per algorithm and size¶
Bytes per second, higher is better. Below a few hundred bytes a call's fixed cost dominates, which is what the first rows show rather than hide; the 64 MiB row is memory-bound rather than hash-bound, and every algorithm converges there.
| payload | xxh32 | xxh64 | xxh3-64 | xxh3-128 |
|---|---|---|---|---|
| 1 B | 0.25 GB/s | 0.16 GB/s | 0.21 GB/s | 0.18 GB/s |
| 4 B | 0.70 GB/s | 0.73 GB/s | 0.88 GB/s | 0.78 GB/s |
| 16 B | 2.70 GB/s | 2.81 GB/s | 3.96 GB/s | 2.58 GB/s |
| 64 B | 5.12 GB/s | 4.87 GB/s | 9.39 GB/s | 5.28 GB/s |
| 128 B | 5.86 GB/s | 6.52 GB/s | 11.61 GB/s | 7.41 GB/s |
| 240 B | 6.15 GB/s | 7.86 GB/s | 13.57 GB/s | 8.74 GB/s |
| 1 KiB | 6.48 GB/s | 11.57 GB/s | 20.52 GB/s | 16.44 GB/s |
| 64 KiB | 6.55 GB/s | 12.99 GB/s | 28.13 GB/s | 27.55 GB/s |
| 1 MiB | 6.43 GB/s | 12.74 GB/s | 25.84 GB/s | 25.88 GB/s |
| 64 MiB | 4.58 GB/s | 5.97 GB/s | 6.54 GB/s | 6.64 GB/s |
XXH3 is roughly four times XXH32 and twice XXH64 once a payload is worth vectorizing, which is
why it is the default and what stable_hash answers.
What this module costs over the protocol it wraps¶
Both rows hash the same bytes with the same implementation, so the difference is the argument normalization at this module's boundary and nothing else.
| payload | xxhash::xxh3 |
direct twox-hash call |
DigestAlgorithm::digest |
|---|---|---|---|
| 1 B | 4.9 ns | 4.1 ns | 4.9 ns |
| 64 B | 7.4 ns | 8.1 ns | 7.3 ns |
| 240 B | 19.0 ns | 22.5 ns | 19.3 ns |
| 4 KiB | 141.9 ns | 124.1 ns | 145.6 ns |
| 1 MiB | 37.8 µs | 37.0 µs | 39.1 µs |
At 1 byte the wrapper costs about 0.8 ns, which is a call's own overhead rather than anything
this module does with the bytes; from 4 KiB up the rows sit inside each other's run-to-run
spread, in both directions. Carrying the algorithm in a Digest costs nothing measurable.
Streaming, and digesting a stored object¶
| case | time | throughput |
|---|---|---|
| one-shot, 1 MiB | 0.040 ms | 26.00 GB/s |
| streamed in 64 KiB windows, 1 MiB | 0.040 ms | 26.42 GB/s |
| one-shot, 64 MiB | 9.153 ms | 7.33 GB/s |
| streamed in 64 KiB windows, 64 MiB | 8.938 ms | 7.51 GB/s |
Streaming in the window pstream_bytes yields costs nothing against hashing the payload whole,
which is what makes the handle read below free of a trade-off.
| 64 MiB local file | time | peak resident after |
|---|---|---|
read_digest |
8.85 ms | 77.9 MiB (unchanged) |
read_all_bytes then digest |
72.98 ms | 140.5 MiB |
That memory column is the reason read_digest exists. The streamed read left the process's
high-water mark exactly where it found it; reading the value whole added the file. The digest
read is also 8x faster here, because it never allocates or fills 64 MiB it is going to throw
away - and the gap widens with the file, while the memory gap becomes the whole difference
between working and not.
| case | time |
|---|---|
Hashed<H> write-through, 4 MiB |
0.762 ms |
| plain write then a digest pass, 4 MiB | 0.948 ms |
std::io::copy, 4 MiB |
0.322 ms |
through DigestReader |
0.685 ms |
through DigestWriter |
0.540 ms |
Hashed<H> saves the second pass, which is the 0.19 ms. The reader and writer pay for the hash
on top of a copy that was already happening, which is the point: the alternative is copying and
then reading the payload again.
The value feed¶
Nanoseconds per value. The feed row reuses one state, which is what an Arrow column does; the
digest row builds a fresh state per value, which is what a single call pays - XXH3 keeps its
secret on the heap, so that construction is the gap. The feed itself allocates nothing, which
rust/tests/allocations.rs pins with the counting allocator rather than this measuring.
| value | feed into a reused state | digest |
stable_hash |
|---|---|---|---|
| leaf string | 58.4 ns | 64.7 ns | 54.7 ns |
| integer | 50.8 ns | 57.5 ns | 47.5 ns |
| decimal | 74.9 ns | 81.7 ns | 75.7 ns |
| four-column row | 157.0 ns | 168.7 ns | 158.3 ns |
| 64-field record | 2.32 µs | 2.29 µs | 2.28 µs |
| 32-deep nest | 1.43 µs | 1.43 µs | 1.39 µs |
stable_hash on the short canonical renderings it actually sees:
| value | stable_hash |
the same bytes through xxh3 |
|---|---|---|
| field name (19 chars) | 356.1 ns | 6.8 ns |
| URI (52 chars) | 98.1 ns | 7.1 ns |
| datatype expression (78 chars) | 725.5 ns | 13.9 ns |
At these lengths the hash is the small half by more than an order of magnitude: what the first column measures is the canonical rendering, and the second is the algorithm on its own. The pre-swap FNV-1a numbers are not reproducible from this tree, and deliberately so - the fold is deleted, because one hash contract is worth more than a few nanoseconds on a short string that the rendering dominates anyway.
Arrow row digests¶
65,536 rows, four columns.
| case | time | per row |
|---|---|---|
buffer path (int64, utf8, float64, utf8) |
11.47 ms | 175 ns |
| scalar fallback (same shape, text dictionary-encoded) | 20.84 ms | 318 ns |
materializing each row as a Scalar first |
29.32 ms | 447 ns |
| buffer path, XXH3-128 | 11.24 ms | 172 ns |
The two paths answer the same digests; the fallback row is the identical logical content with its two text columns dictionary-encoded, which has no buffer arm and reads through the shared scalar boundary. Reading buffers directly is worth about 1.8x against that, and 2.6x against building every row as a value first. Answering 128 bits instead of 64 costs nothing.
At the bindings¶
python/benchmarks/xxhash.py --min-time 0.1 --repeat 5, release wheel, against the xxhash
package binding C libxxhash on the same 1,080,000-byte payload:
xxh3 payload 44773.2 ns 24.12 GB/s
xxh3 payload (C libxxhash) 66506.4 ns 16.24 GB/s
xxh64 payload 87825.4 ns 12.30 GB/s
xxh64 payload (C libxxhash) 85947.4 ns 12.57 GB/s
xxh3 payload (bytearray) 75534.4 ns 14.30 GB/s
xxh3 payload (memoryview) 75835.4 ns 14.24 GB/s
xxh3 payload (str) 44825.5 ns 24.09 GB/s
xxh3 1 B 165.8 ns 0.01 GB/s
xxh3 1 B (C libxxhash) 78.0 ns 0.01 GB/s
Two honest numbers here. The bytearray and memoryview rows are 1.7x slower than bytes,
because those buffers cannot be borrowed under this crate's own safety rule and are read
through a bounded window instead - the cost of never allocating a copy of the payload. And at
one byte this binding is twice the C one: that is PyO3's call plus the buffer dispatch, and it
stops mattering by a kilobyte. Where the payload is worth vectorizing, the Rust XXH3 kernel is
ahead of the C build available here.
npm run bench:xxhash in node/, release addon, on the same 1,080,000-byte payload:
xxh3 1 B 495.9 ns 0.00 GB/s
xxh3 1024 B 505.0 ns 2.03 GB/s
xxh3 65536 B 2756.8 ns 23.77 GB/s
xxh32 payload 170537.4 ns 6.33 GB/s
xxh64 payload 87036.2 ns 12.41 GB/s
xxh3 payload 48139.1 ns 22.43 GB/s
xxh128 payload 51937.5 ns 20.79 GB/s
xxh3 payload (Uint8Array) 50711.2 ns 21.30 GB/s
xxh3 payload (string) 364511.2 ns 2.96 GB/s
xxh3 payload (streamed 64 KiB) 71816.4 ns 15.04 GB/s
scalar leaf digest 2399.2 ns 0.00 GB/s
The one-byte row is 496 ns against Python's 166 ns: NAPI's call overhead, flat across every
size below a kilobyte, and gone by 64 KiB where both bindings reach the Rust kernel's own
speed. A Buffer is borrowed; a string is 7.5x slower because it is UTF-8 encoded on the way
in, so hash bytes rather than text when the text is already encoded somewhere.
DigestAlgorithm: a hash over anything¶
DigestAlgorithm names one xxHash algorithm and is the only place a name selects an
implementation, the way Codec is for content codings. Digest is the value it answers -
the algorithm carried with the number, so xxh64 and xxh3-64, both 64 bits wide, can
never be confused for one another. Digester is the runtime-selected streaming state, what
Encoder is to Codec.
use yggdryl::{Digest, DigestAlgorithm};
let digest = DigestAlgorithm::Xxh3.digest(b"AAPL");
assert_eq!(digest.algorithm(), DigestAlgorithm::Xxh3);
assert_eq!(Digest::from_str(&digest.to_string())?, digest);
assert_eq!(digest.into_bytes().len(), DigestAlgorithm::Xxh3.width());
// A caller who knows the algorithm at compile time uses the concrete state in
// `yggdryl::xxhash` and pays no dispatch; this is the form for one held in a
// variable.
let mut digester = DigestAlgorithm::Xxh3.digester();
digester.write_bytes(b"AA");
digester.write_bytes(b"PL");
assert_eq!(digester.as_digest(), digest);
// Only the XXH3 pair takes a custom secret; every algorithm takes a seed.
assert!(!DigestAlgorithm::Xxh64.is_secretable());
assert!(DigestAlgorithm::Xxh3.is_secretable());
assert_eq!(
DigestAlgorithm::ALL.map(DigestAlgorithm::as_str),
["xxh32", "xxh64", "xxh3-64", "xxh3-128"],
);
xxhash owns the four implementations, the resumable states, the handle and
Arrow surfaces, and the canonical Scalar byte feed every stable_hash reads.