Skip to content

Releases: mi-for-the-rust-of-us/anamnesis

Release list

v0.7.4

Choose a tag to compare

@github-actions github-actions released this 15 Aug 12:45

Added

  • The remember dequantisation output type is now caller-chosen
    (src/model.rs, src/remember/{fp8,gptq,awq,bnb}.rs). TargetDtype gains
    F32 and F16, and the four kernel families remember dispatches over
    become generic over the OutputElement trait v0.7.3 introduced for GGUF.
    The CLI spells it amn remember <file> --to bf16|f32|f16; the library takes
    it as the existing TargetDtype argument. Every dequantize_*_to_bf16 entry
    point remains as an #[inline] Bf16Out wrapper, so no existing caller
    changes
    .

    With this, the output dtype is a caller-chosen parameter on every path the
    crate exposes. convert --out-dtype also widens from GGUF-input-only to
    every dequantising input, because the quantised-safetensors kernels that
    blocked it are the ones this release generalises.

  • InspectOptions, a builder mirroring RememberOptions /
    ConvertOptions, plus ParsedModel::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_dtype records
    which width a figure was computed for.

  • F32 cross-validation for all four remember kernel families, compared
    against the canonical libraries bit for bit with no tolerance: FP8 against
    PyTorch, GPTQ against GPTQModel, AWQ against AutoAWQ, and BnB
    against bitsandbytes. Every fixture container gained a magic prefix and a
    version, and every generator gained a drift guard that refuses to overwrite a
    committed BF16 golden that no longer reproduces.

  • remember_f32_whole_model bench group in benches/convert.rs, added
    alongside remember_bf16_whole_model rather than renaming it, so the BF16
    CodSpeed history that serves as this phase's baseline survives.

  • docs/tutorials/choosing-an-output-dtype.md, plus four FAQ entries on
    choosing a dtype, the size cost, the mixed-dtype passthrough policy, and
    whether the crate is still bit-exact at F32.

Fixed

  • BnB INT8 dequantisation was 1 ULP out on 26.9 % of elements at F32
    (src/remember/bnb.rs). The kernel hoisted a per-row scale and computed
    w × (SCB / 127), while bitsandbytes' int8_vectorwise_dequant computes
    (w × SCB) × (1/127). Those are the same real number and round to the same
    BF16, which is why five releases of BF16 cross-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 1 ULP. The constant was never the problem
    (f32(7.874015718698502e-3) and f32(1.0/127.0) are the same bits, now
    asserted at compile time); only the multiply order was. anamnesis now uses the
    canonical association. No BF16 output 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_bf16 were ~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, where e4m3_to_f32_bits, e4m3_to_scaled_f32,
    f32_bits_to_bf16_bits and unpack_gptq were private, non-#[inline] and
    therefore opaque: a function call per element. remember never 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 while Display still
    printed a literal (BF16), so asking for the F32 figure rendered a doubled
    number under a BF16 label. The line now reads the width off
    output_dtype, with a regression test.

  • amn remember --to f32 reported the BF16 size (src/cli.rs). The
    summary line built its InspectInfo with From<&SafetensorsHeader>, which is
    hard-wired to the BF16 default, 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 via InspectOptions.

Changed

  • to_bf16_bytes (the bnb-gated encode-side helper) now rounds to nearest
    even, matching the crate's f32_bits_to_bf16_bits convention its own doc
    comment already claimed; it previously truncated.

  • The remember per-dtype determinism tests now use a fixture sized off
    MIN_PARALLEL_BYTES. The previous 32-byte fixture was far below the 4 MiB
    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 invoked cargo test -- --quiet and then
    counted lines matching ^test .* ok$, but --quiet prints 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 the test result:
    summary line and treat a zero count as a failure. The suite descriptions
    also said only GGUF was verified at F32; 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.ps1

That 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

Choose a tag to compare

@PCfVW PCfVW released this 12 Aug 19:46

Added

  • The GGUF dequantisation output type is now caller-chosen
    (src/remember/output.rs, src/remember/gguf.rs). A new sealed
    OutputElement trait with three implementations, Bf16Out (the unchanged
    default), F32Out and F16Out, replaces the hard-coded BF16 narrowing that
    every kernel has performed since the crate's first commit. Two new entry
    points, dequantize_gguf::<E> and dequantize_gguf_blocks::<E>;
    dequantize_gguf_to_bf16 and dequantize_gguf_blocks_to_bf16 remain as
    #[inline] Bf16Out wrappers, so no existing caller changes.

    Why it matters. BF16 keeps 8 significand bits, but a Q8_0 value is an
    f16 scale times an int8 and needs about 18; Q6_K needs 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 as BF16's half-ULP, so the crate was adding
    rounding comparable to the error the format exists to avoid. F32Out adds no
    narrowing step of its own, so its output is the reference's f32.

    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 to GGUF, where all 24
    kernels funnel through one pass-2 writer, and is why the remember path'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 at BF16 and F16 but 128 B / 1024 B at F32. A sink that
    hard-codes chunks_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 misreading F32 output as
    twice as many BF16 values.

    F16 follows plain IEEE semantics via half::f16::from_f32: overflow to
    infinity, flush to zero below roughly 2⁻²⁴, round-to-nearest-even between.
    Deliberately not saturating, which would fabricate a value no reference
    produces and put F16 cross-validation permanently at odds with NumPy and
    PyTorch. This range is reachable in real data: MXFP4's E8M0 scale spans
    2⁻¹²⁸ to 2¹²⁷. Note that F16 is 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_tensor remains
    as the BF16 spelling. Without it, a caller wanting F32 for 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.

  • convert and the CLI can now choose that output dtype
    (src/convert.rs, src/cli.rs, docs/FAQ.md).
    ConvertOptions::output_dtype with a with_output_dtype builder matching
    with_threads, and amn convert --out-dtype bf16|f32|f16. The flag is named
    --out-dtype rather than reusing --to because on convert --to already
    selects the output format; on remember, --to already selects a dtype, so
    that subcommand needs no new flag when Phase 7.4 lands.

    The option takes a Dtype rather 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. remember and
    convert have always emitted mixed-dtype files, with dequantised tensors at
    BF16 and passthrough tensors keeping their source dtype. --out-dtype
    widens dequantised tensors only: an F16 norm stays an F16 norm and an
    F32 tensor 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 in convert_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-BF16 request in v0.7.3. A quantised safetensors input
    returns Unsupported naming 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. NPZ and .pth dequantise nothing, so the option is
    vacuous there and is accepted rather than refused, since erroring on
    --out-dtype f32 for an already-F32 NPZ would 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 the BF16 suite.

    A derived output filename now names the dtype it actually holds
    (derive_output_path_for_dtype, new; derive_output_path kept unchanged as
    the BF16 spelling, so no caller breaks). ConvertTarget::suffix() returns
    bf16 for the safetensors target, which was correct while BF16 was the only
    possible answer; without this, amn convert --out-dtype f32 would have
    written F32 tensors into model-bf16.safetensors. That is worse than an
    unhelpful name because it is an actively wrong one. The gguf and bnb-nf4
    targets keep their own suffix, since there it names a container or an encoding
    rather than an element type.

  • All 22 GGUF kernels are now cross-validated at F32, bit-exactly
    (tests/cross_validation_gguf.rs,
    tests/fixtures/gguf_reference/generate_gguf.py). Every cross-validation
    before this rounded the gguf-py reference to BF16 before comparing, which
    discarded 16 mantissa bits: no kernel's f32 had ever been checked at full
    width.
    A kernel could have associated its arithmetic differently from the
    reference ((d·sc)·q against d·(sc·q), or a contraction on a d·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 to gguf-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
    f32 and so does gguf-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 bits BF16 cannot 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 1 ULP while the BF16
    comparison stays green, which is the hidden-16-bits problem shown directly.

    Fixtures move to a versioned container (magic AMNG, version 2) carrying
    the BF16 and F32 goldens 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. The BF16 one is not derived by
    rounding the F32 one 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 --upgrade rebuilds the fixtures from their own raw
    quantised bytes
    , so the F32 goldens are reproducible from a clean checkout
    with pip install gguf numpy and no multi-GiB model download. Each upgrade
    re-derives the BF16 golden and refuses to proceed unless it reproduces the
    committed one byte for byte, so a differing gguf version 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 .crate stayed 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 the CONVENTIONS.md race-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:

    1. A --bin target rather than cargo test. -Zbuild-std is unusable
      with cargo test
      (cargo#13146, open since
      2023) because cargo test also builds dev-dependencies, several of which
      are crates std itself depends on. cargo run --bin builds none.
    2. -Cunsafe-allow-abi-mismatch=sanitizer, because compiler_builtins
      ...
Read more