Releases: mi-for-the-rust-of-us/anamnesis
Release list
v0.7.4
Added
-
The
rememberdequantisation output type is now caller-chosen
(src/model.rs,src/remember/{fp8,gptq,awq,bnb}.rs).TargetDtypegains
F32andF16, and the four kernel familiesrememberdispatches over
become generic over theOutputElementtrait v0.7.3 introduced forGGUF.
The CLI spells itamn remember <file> --to bf16|f32|f16; the library takes
it as the existingTargetDtypeargument. Everydequantize_*_to_bf16entry
point remains as an#[inline]Bf16Outwrapper, so no existing caller
changes.With this, the output dtype is a caller-chosen parameter on every path the
crate exposes.convert --out-dtypealso widens fromGGUF-input-only to
every dequantising input, because the quantised-safetensors kernels that
blocked it are the ones this release generalises. -
InspectOptions, a builder mirroringRememberOptions/
ConvertOptions, plusParsedModel::inspect_with_options. The
dequantised-size estimate that feeds the inspect-before-parse policy gate is
only meaningful against a specific output width, so it can now be asked for
the width the caller actually intends.InspectInfo::output_dtyperecords
which width a figure was computed for. -
F32cross-validation for all fourrememberkernel families, compared
against the canonical libraries bit for bit with no tolerance:FP8against
PyTorch,GPTQagainstGPTQModel,AWQagainstAutoAWQ, andBnB
againstbitsandbytes. Every fixture container gained a magic prefix and a
version, and every generator gained a drift guard that refuses to overwrite a
committedBF16golden that no longer reproduces. -
remember_f32_whole_modelbench group inbenches/convert.rs, added
alongsideremember_bf16_whole_modelrather than renaming it, so theBF16
CodSpeed history that serves as this phase's baseline survives. -
docs/tutorials/choosing-an-output-dtype.md, plus fourFAQentries on
choosing a dtype, the size cost, the mixed-dtype passthrough policy, and
whether the crate is still bit-exact atF32.
Fixed
-
BnBINT8dequantisation was 1ULPout on 26.9 % of elements atF32
(src/remember/bnb.rs). The kernel hoisted a per-row scale and computed
w × (SCB / 127), whilebitsandbytes'int8_vectorwise_dequantcomputes
(w × SCB) × (1/127). Those are the same real number and round to the same
BF16, which is why five releases ofBF16cross-validation reported
0/65536 mismatches and never saw it. Measured at full width against the
canonical kernel: 17610/65536 (26.9 %) of elements differed, every one of
them by exactly 1ULP. The constant was never the problem
(f32(7.874015718698502e-3)andf32(1.0/127.0)are the same bits, now
asserted at compile time); only the multiply order was. anamnesis now uses the
canonical association. NoBF16output byte changes. Costs 1.04× on that
kernel (16.82 → 17.48 ms at 4096 × 11008), which is one extra packed multiply
and is the honest price of exactness at every width. -
Downstream callers of
dequantize_*_to_bf16were ~2.9× slower than
v0.7.3 (src/remember/{fp8,gptq}.rs). Turning those entry points into
#[inline]generic wrappers meant an external crate instantiated the generic
in its own crate, wheree4m3_to_f32_bits,e4m3_to_scaled_f32,
f32_bits_to_bf16_bitsandunpack_gptqwere private, non-#[inline]and
therefore opaque: a function call per element.remembernever suffered it,
calling the generic from inside the library. Fixed by marking those four
helpers#[inline]. -
InspectInfo's rendered size line claimed the wrong dtype
(src/inspect.rs). The estimate became dtype-aware whileDisplaystill
printed a literal(BF16), so asking for theF32figure rendered a doubled
number under aBF16label. The line now reads the width off
output_dtype, with a regression test. -
amn remember --to f32reported theBF16size (src/cli.rs). The
summary line built itsInspectInfowithFrom<&SafetensorsHeader>, which is
hard-wired to theBF16default, so every width printed the same figure: a
file holding 272 B of payload was announced as 144 B. The written file was
always correct; only the number beside it was wrong. It now sizes the estimate
at the requested width viaInspectOptions.
Changed
-
to_bf16_bytes(thebnb-gated encode-side helper) now rounds to nearest
even, matching the crate'sf32_bits_to_bf16_bitsconvention its own doc
comment already claimed; it previously truncated. -
The
rememberper-dtype determinism tests now use a fixture sized off
MIN_PARALLEL_BYTES. The previous 32-byte fixture was far below the 4MiB
parallel threshold, so every thread-count assertion had been exercising the
sequential path. -
scripts/verify-claims.{ps1,sh}counted the tests it ran, and the count
was always zero. Both scripts invokedcargo test -- --quietand then
counted lines matching^test .* ok$, but--quietprints one dot per test
and never emits those lines, so every suite reported "PASS 0 tests". The
pass/fail verdict was real (it keyed off the exit status), but a suite that
compiled and ran nothing was indistinguishable from one that verified 22
kernels. That is a poor property for the script the README points readers at
to substantiate the correctness claims. Both now parse thetest result:
summary line and treat a zero count as a failure. The suite descriptions
also said onlyGGUFwas verified atF32; every dequantising family is,
as of this release.
Verifying the correctness claims
The published crate on crates.io excludes tests/ (0.19 MiB instead of
4.8 MiB). The cross-validation corpus ships here instead: the Source
code (tar.gz) asset below contains tests/ verbatim.
tar xzf anamnesis-<tag>.tar.gz && cd anamnesis-*
./scripts/verify-claims.sh # or scripts\verify-claims.ps1That runs the cross_validation_* suites against goldens produced by
each format's own canonical library. Note what it does and does not
prove: it checks anamnesis against goldens committed to this repo, and
does not re-derive those goldens from PyTorch / gguf-py. See the
script output for how to do that stronger check.
v0.7.3
Added
-
The
GGUFdequantisation output type is now caller-chosen
(src/remember/output.rs,src/remember/gguf.rs). A new sealed
OutputElementtrait with three implementations,Bf16Out(the unchanged
default),F32OutandF16Out, replaces the hard-codedBF16narrowing that
every kernel has performed since the crate's first commit. Two new entry
points,dequantize_gguf::<E>anddequantize_gguf_blocks::<E>;
dequantize_gguf_to_bf16anddequantize_gguf_blocks_to_bf16remain as
#[inline]Bf16Outwrappers, so no existing caller changes.Why it matters.
BF16keeps 8 significand bits, but aQ8_0value is an
f16scale times anint8and needs about 18;Q6_Kneeds 24. Measured on
SmolLM2-135M-Q4_K_M, only 3 to 20 % of dequantised values are exactly
BF16-representable. The usual defence, that quantisation error dwarfs the
rounding, fails precisely where it matters most:Q8_0's own quantisation
step is the same order asBF16's half-ULP, so the crate was adding
rounding comparable to the error the format exists to avoid.F32Outadds no
narrowing step of its own, so its output is the reference'sf32.All 24 kernel bodies are untouched. Only their signatures thread the type
parameter through; the bit manipulation, the formulas and every annotation are
byte-identical, which the 22 existing cross-validation fixtures confirm by
still passing unchanged. That economy is specific toGGUF, where all 24
kernels funnel through one pass-2 writer, and is why therememberpath's
four families are a separate phase.The streaming sink's block length is now dtype-dependent:
QK × E::BYTES,
so 64 B / 512 B atBF16andF16but 128 B / 1024 B atF32. A sink that
hard-codeschunks_exact(2)is correct only for the 2-byte types. The public
docs carry the table and a test asserts the observed length per output type,
so the assumption fails loudly rather than silently misreadingF32output as
twice as manyBF16values.F16follows plain IEEE semantics viahalf::f16::from_f32: overflow to
infinity, flush to zero below roughly2⁻²⁴, round-to-nearest-even between.
Deliberately not saturating, which would fabricate a value no reference
produces and putF16cross-validation permanently at odds withNumPyand
PyTorch. This range is reachable in real data:MXFP4'sE8M0scale spans
2⁻¹²⁸to2¹²⁷. Note thatF16is not uniformly the better 2-byte
choice, since it buys 3 significand bits and pays a far narrower exponent
range.The trait is sealed. Its contract is a byte-level invariant the
cross-validation depends on, and an outside implementation could break it
while every test stayed green. Sealing is also the reversible direction:
un-sealing later is not a breaking change, sealing later would be. -
ParsedGguf::dequantize_tensor_as::<E>(src/parse/gguf.rs), the
per-tensor counterpart of the whole-file option.dequantize_tensorremains
as theBF16spelling. Without it, a caller wantingF32for a single tensor
would have had to re-implement the offset, byte-length and element-count
validation that method exists to encapsulate, which would have left the
per-tensor path worse off than the whole-file one. -
convertand the CLI can now choose that output dtype
(src/convert.rs,src/cli.rs,docs/FAQ.md).
ConvertOptions::output_dtypewith awith_output_dtypebuilder matching
with_threads, andamn convert --out-dtype bf16|f32|f16. The flag is named
--out-dtyperather than reusing--tobecause onconvert--toalready
selects the output format; onremember,--toalready selects a dtype, so
that subcommand needs no new flag when Phase 7.4 lands.The option takes a
Dtyperather than introducing a narrower enum, because
that is already the type a hub tensor carries, so no third dtype vocabulary
enters the crate. Values outside{BF16, F32, F16}are rejected at the
boundary with a message listing what is accepted.Passthrough policy, now stated rather than assumed.
rememberand
converthave always emitted mixed-dtype files, with dequantised tensors at
BF16and passthrough tensors keeping their source dtype.--out-dtype
widens dequantised tensors only: anF16norm stays anF16norm and an
F32tensor stays byte-identical. Widening a passthrough tensor would invent
precision that was never in the file while doubling its size, and a caller who
wants a uniform-dtype file wants a cast pass, which is a different operation.
Asserted per tensor inconvert_honours_every_output_dtype_end_to_end, not
merely documented.Scope, made explicit in the error rather than silently. Only the
GGUF
reader honours a non-BF16request in v0.7.3. A quantised safetensors input
returnsUnsupportednaming v0.7.4 and the reason (those four families narrow
inside their hot loops), so the caller never gets a file whose dtype differs
from the request.NPZand.pthdequantise nothing, so the option is
vacuous there and is accepted rather than refused, since erroring on
--out-dtype f32for an already-F32NPZwould be hostile.Determinism is re-established per dtype: output is byte-identical across
{1, 2, 4, 8}threads at each of the three widths, rather than assumed to
carry over from theBF16suite.A derived output filename now names the dtype it actually holds
(derive_output_path_for_dtype, new;derive_output_pathkept unchanged as
theBF16spelling, so no caller breaks).ConvertTarget::suffix()returns
bf16for the safetensors target, which was correct whileBF16was the only
possible answer; without this,amn convert --out-dtype f32would have
writtenF32tensors intomodel-bf16.safetensors. That is worse than an
unhelpful name because it is an actively wrong one. Theggufandbnb-nf4
targets keep their own suffix, since there it names a container or an encoding
rather than an element type. -
All 22
GGUFkernels are now cross-validated atF32, bit-exactly
(tests/cross_validation_gguf.rs,
tests/fixtures/gguf_reference/generate_gguf.py). Every cross-validation
before this rounded thegguf-pyreference toBF16before comparing, which
discarded 16 mantissa bits: no kernel'sf32had ever been checked at full
width. A kernel could have associated its arithmetic differently from the
reference ((d·sc)·qagainstd·(sc·q), or a contraction on ad·q - dmin·m
line) and every fixture would still have passed.The result: 22 of 22 pass, exactly, with no tolerance. This was the step
the ROADMAP told us to budget for failing, so the null result is worth
stating plainly rather than passing over. All 22 production kernels already
associate their arithmetic identically togguf-py. Nothing needed fixing;
what changed is that it is now verified rather than assumed.Exact bit equality is the right bar, not an epsilon: anamnesis computes in
f32and so doesgguf-py, so identical operations in identical order must
produce identical bits, and any difference is a real divergence rather than
accumulated noise.The comparison has teeth, and that was demonstrated rather than asserted.
Across the 22 fixtures, 76.5 % of the 1 441 792 reference values (1 102 549
of them) carry mantissa bitsBF16cannot represent, so the new assertion
reads information the old one discarded. Flipping a single mantissa bit in one
golden makes exactly one of 65 536 elements fail at 1ULPwhile theBF16
comparison stays green, which is the hidden-16-bits problem shown directly.Fixtures move to a versioned container (magic
AMNG, version 2) carrying
theBF16andF32goldens side by side; the previous layout had neither
magic nor version and so could not be extended unambiguously. The Rust reader
rejects any other version by name instead of misreading offsets.Both goldens come from
gguf-py. TheBF16one is not derived by
rounding theF32one in Rust, which would compare anamnesis's output against
a golden produced by anamnesis's own rounding, i.e. the circular-fixture class
that shipped three green bugs in v0.6.4.generate_gguf.py --upgraderebuilds the fixtures from their own raw
quantised bytes, so theF32goldens are reproducible from a clean checkout
withpip install gguf numpyand no multi-GiBmodel download. Each upgrade
re-derives theBF16golden and refuses to proceed unless it reproduces the
committed one byte for byte, so a differingggufversion fails loudly
instead of silently rebasing the reference. All 22 verified unchanged.The fixtures grew ~6 MB, which costs the published crate nothing:
tests/is
now excluded, and the.cratestayed at 0.60 MiB across this change. -
ThreadSanitizer is now gating, with an instrumented
std
(.github/workflows/tsan.yml,src/bin/tsan_harness.rs). v0.7.2 shipped
with theCONVENTIONS.mdrace-detector rule still open — the job existed but
could not be made green, and its CHANGELOG entry says so. It can now: the
parallel dispatch reports zero races across{1, 2, 4, 8}threads plus
the hardware-resolved default, and every budget is byte-identical.Getting there needed three constraints to hold at once, each learned from a
red build and each individually insufficient:- A
--bintarget rather thancargo test.-Zbuild-stdis unusable
withcargo test
(cargo#13146, open since
2023) becausecargo testalso builds dev-dependencies, several of which
are cratesstditself depends on.cargo run --binbuilds none. -Cunsafe-allow-abi-mismatch=sanitizer, becausecompiler_builtins
...
- A