Skip to main content

mantissa

ci License Language Python Dependencies

A low-precision neural-network compute core in C, with a Python binding.

mantissa is a from-scratch AI compute core: dense layers and 2-D convolution, forward and backward, with weights and activations stored in a narrow float format of your choice — bfloat16, fp16, FP8 (E4M3/E5M2), FP4 (E2M1), or a custom 1·7·24 type — while every accumulator stays float32. That is the standard mixed-precision recipe, narrow storage / wide accumulate, and it is the whole design: on a dense layer the budget is memory bandwidth, so halving the stored size of a weight is worth nearly as much as halving the runtime.

It is dependency-free C11 with hand-written NEON and runtime-dispatched AVX2 kernels in one portable binary, a thread pool, and a ctypes Python layer. It runs on the CPU today.

Roadmap (not yet implemented): a GPU backend, and the sequence models the same primitive already implies — word2vec, GRU/LSTM, and transformer-shaped work. See DESIGN.md §3 for why tk_linear_forward is the shared building block of all of them.

Every performance number in this repository is measured, and the counter-stories are written down too: PERFORMANCE.md says where narrow storage does not pay, and DESIGN.md lists the optimizations that were tried and rejected with the measurement that killed them.

Started by Tekin Ertekin (2024); later refactored with Claude Code — see AUTHORS.md.

Version history and per-release benchmark scores: RELEASES.md.


Low-precision, in numbers

mantissa keeps weights and activations in a narrow storage format and accumulates in float32 — narrow storage, wide accumulate. To show what that buys, a linear MNIST classifier (784→10) is trained once in float32, then its weights are post-training-quantized into each storage format via Mantissa.prepare() (rebuild the core with make DTYPE=N). Same weights, same test set — only the storage type changes:

MNIST accuracy vs. model footprint across mantissa storage formats

storage format bits (S-E-M) bytes/param model size MNIST test acc
float32 (baseline) 1-8-23 4 30.6 KB 81.20 %
tekin32 1-7-24 4 30.6 KB 81.20 %
fp16 1-5-10 2 15.3 KB 81.20 %
bfloat16 1-8-7 2 15.3 KB 81.25 %
fp8 E5M2 1-5-2 1 7.7 KB 81.25 %
tekin8 (fp8 E4M3) 1-4-3 1 7.7 KB 81.10 %
fp4 E2M1 (MXFP4) 1-2-1 ½ 3.8 KB 12.63 % → 78.85 % with block scaling

fp8 holds float32 accuracy at 4× less memory (−0.1 pt); fp16/bf16 are effectively lossless at 2×. fp4 is a step too far for naïve post-training quantization: its magnitudes run 0.5 to 6, so weights with std ~0.05 quantize almost entirely to zero.

Per-block scaling fixes that. With one shared power-of-two scale per 32 elements (MX v1.0, stored as an E8M0 byte), the same 4-bit weights give 78.85 % against float32's 79.57 % — at 3.8 KB instead of 30.6 KB. Pass MANTISSA_BLOCKED=1 to the demo, or use prepare_blocked() from Python. It costs throughput: the blocked kernel measures 3.4–4.7× slower than the flat one, uniformly across storage types, because the scale is applied per block. Use it where memory decides and the flat path where speed does.

Run it yourself:

make DTYPE=4 lib
MANTISSA_LIB=build/libmantissa.dylib python examples/mnist_demo.py   # .so on Linux

trains a classifier on MNIST and reports accuracy, throughput, and footprint at the chosen storage precision (needs only numpy + matplotlib). MANTISSA_LIB is what selects that build: without it the binding prefers the bundled library, which is bfloat16, so DTYPE= would appear to have no effect.

The linear model tops out near 81 %; the point is the relative cost of each format, not the absolute number. Convolutions currently run in float32, so this low-precision story is about the dense/linear engine.

How it gets its speed: see docs/PERFORMANCE.md — the SIMD kernels (NEON + runtime-dispatched AVX2), im2col+GEMM convolution, and threading, each with measured numbers.


The mantissa family

Part of the mantissa family: a low-precision engine written in C, with small Python packages built on top. Each package sits under the one it depends on — ⭐ marks where you are, and every other name links to its repo.

  • mantissa — low-precision neural-network engine in C (the core) (you are here)

How it works

mantissa is a shared library with a plain C ABI, so any language that can call C — Python (via ctypes), C/C++, Rust, Go — drives the same engine. The caller passes ordinary float32 arrays in and gets float32 back; inside, weights are kept in a narrow type to save memory.

mantissa architecture: callers to the C ABI to the engine core

A call is one of three kinds:

  • Build — quantize weights from float32 into the compact storage type (once).
  • Forward pass (tk_linear_forward) — compute y = activation(W·x + b). Called forward because data flows forward through the network, input → output (also feed-forward). There is no "fast" in it — the opposite direction is the backward pass.
  • Backward pass / training (tk_linear_backwardtk_sgd_step) — gradients flow backward, output → input, and the weights are updated.

Whichever it is, the compute core does the same underneath: read the narrow weights, accumulate in float32, apply an activation, return float32.

Performance at a glance

A 2048×2048 dense layer (4.2M parameters) on an Apple M-series laptop, default bfloat16 (make bench / make benchbp):

value
forward pass (10 threads) 0.10 ms (~83 GFLOP/s)
forward pass (1 thread) 0.35 ms (~29 GFLOP/s)
backward pass (10 threads) 0.33 ms (~39 GFLOP/s)
backward pass (1 thread) 1.01 ms (~12 GFLOP/s)
weight memory 8 MB — half of float32
relu over 4M values 0.41 ms

Forward and backward use explicit NEON kernels (bfloat16 leads — half the bytes, same FMAs) and a persistent thread pool that splits rows across cores for large layers. Numbers are for the default bfloat16 on a 10-core Apple laptop.

Memory is the headline at scale — it decides whether a model loads at all:

model float32 mantissa (bf16) mantissa (1-byte)
1B params 4.0 GB 2.0 GB 1.0 GB
7B params 28 GB 14 GB 7 GB

How it stays small and fast

  1. Narrow weight storage — weights are the bulk of a model, so storing each in 2 bytes (or 1) instead of 4 is where the RAM/VRAM savings come from, and since a dense layer is memory-bandwidth bound, moving fewer bytes is also faster. The precision is a build-time dial (below).
  2. float32 accumulation — compute always sums in float32 so accuracy holds across a layer's millions of terms (mixed precision; Micikevicius 2017).
  3. Tuned kernels — register-blocked GEMV with explicit SIMD FMA kernels (NEON on arm64, AVX2 on x86-64) and a persistent thread pool across cores, branchless activations (sign-bit / fmax / copysign, chosen by benchmark), and stochastic rounding so training survives narrow weights. Details and measurements in docs/DESIGN.md.

The precision dial

Storage precision is one build-time knob (DTYPE); the default needs no config. It is how the core trades accuracy for memory — a means to the speed/RAM goal, not the goal itself.

DTYPE Name Bytes When
0 float32 4 reference / exact
2 bfloat16 2 default — half the RAM, training-safe
1 fp16 2 half RAM, more precision, less range
3 tekin32 4 custom high-fidelity accumulator
4 tekin8 1 FP8 E4M3 — 4× smaller
5 fp8_e5m2 1 FP8, wider range
6 fp4_e2m1 ½* FP4 — the extreme

*fp4 stores two values per byte on the block-scaled path, so the "½" is real there: 4.26 bits per weight including the shared scales, against MXFP4's 4.25. The flat path still stores one per byte. Packing costs ~22% throughput rather than gaining any — see docs/DESIGN.md.

Bit layouts, the tekin formats' design rationale, the hot/cold conversion split, and the current-research context (MX / NVFP4 / posit / IEEE P3109) all live in docs/DESIGN.md. Every value is stored in the selected type but computed in float32.

Accuracy cost, seen

The same value stored in each format and read back (make DTYPE=<n> test). This is the accuracy you trade away for the memory you save — float32 is the reference column:

input float32 tekin32 fp16 bfloat16 e5m2 tekin8
3.14159 3.14159 3.14159 3.14062 3.14062 3.0 3.25
100.0 100 100 100 100 96 104
0.01 0.01 0.01 0.0100021 0.0100098 0.0098 0.00977

fp4_e2m1 is coarser still: its 8 magnitudes are {0, ±0.5, ±1, ±1.5, ±2, ±3, ±4, ±6} — everything rounds onto that grid.

Full benchmarks

The numbers behind Performance at a glance, per dtype. make DTYPE=<n> bench — a 2048×2048 dense layer (4.2M params), plus the activation-dispatch micro-test. Apple M-series laptop, clang -O3; indicative, not absolute.

dtype weight memory GEMV ms/pass GEMV GFLOP/s vectorised read
bfloat16 8.0 MB 0.055 ~153 widen + shift (the shift is the conversion)
fp16 8.0 MB 0.067 ~125 one FCVTL
float32 16.0 MB 0.100 ~84 plain load, bandwidth-bound
fp4 E2M1 4.0 MB 0.286 ~29 two TBL lookups
tekin8 4.0 MB 0.308 ~27 mask/shift + one multiply
fp8 E5M2 4.0 MB 0.371 ~23 scalar — its 256-entry table beats arithmetic

All six rows come from one back-to-back run, so they are comparable with each other; absolute wall-clock on a laptop moves with thermal state and background load, so do not compare them against figures from another session. fp4 and tekin8 sit close enough that their order swaps between runs.

The forward pass is register-blocked (4 output rows share each x load and run 4 independent FMA chains), with explicit SIMD FMA kernels — NEON on arm64, AVX2 on x86-64 (runtime-dispatched via __builtin_cpu_supports, scalar fallback on older CPUs). bfloat16 leads on both axes: it moves half the bytes of float32 for the same FMAs.

The one-byte formats are still conversion-bound rather than bandwidth-bound, but two of the three now have a vectorised read of their own: tekin8 because its E4M3 unpack is bit arithmetic, fp4 because its sixteen values fit what TBL indexes. E5M2 keeps the scalar read on measurement — a 256-entry table in L1 beats reconstructing it arithmetically. Native FP8 hardware would close the remaining gap; see docs/PERFORMANCE.md.

On top, a thread pool splits the output rows across cores for large layers, both forward and backward (the per-kernel numbers above are single-thread):

bfloat16 (2048×2048) 1 thread 10 threads
forward GFLOP/s ~29 ~83 (2.9×)
backward GFLOP/s ~12 ~39 (3.1×)

Scaling is sub-linear because GEMV does only ~2 FLOPs per byte, so it saturates memory bandwidth before compute — float32 (twice the bytes) barely gains, which is exactly why the narrow default matters. Small layers run serially (below a work threshold) so the pool never hurts the millions-of-small-calls case; set MANTISSA_THREADS to tune. Numbers are laptop-noisy.

Backward pass (make DTYPE=<n> benchbp, same 2048×2048 layer):

dtype backward ms/pass backward GFLOP/s SGD update (M weights/s)
float32 1.03 12.21 9379
bfloat16 0.98 12.83 994
tekin8 3.04 4.14 321

Activation dispatch (4M elements): a per-element switch beats a resolved function pointer ~7× for relu and ~1.5× for sigmoid on Apple Silicon — on the x86 CI runner the gap shrinks to ≤1.2× (parity on some cases), so the win is real but platform-dependent. The inline switch vectorizes (relu compiles to a single fmax per lane); an indirect call per element does not. So tk_activate keeps the switch, and the function-pointer API (tk_act_resolve) is reserved for genuinely pluggable dispatch. The step/sign/relu kernels themselves are branchless (sign-bit read, fmax, copysign), each picked by benchmark over the obvious comparison. Measure, don't assume.

Mixed architectures

Every layer configures itself independently — bias is a per-call NULL-able pointer, activation is a per-call argument:

tk_linear_forward(W1, x,  b1,   h1, 6, 4, TK_ACT_TANH);     /* bias + tanh    */
tk_linear_forward(W2, h1, NULL, h2, 5, 6, TK_ACT_RELU);     /* no bias + relu */
tk_linear_forward(W3, h2, b3,   y,  2, 5, TK_ACT_SIGMOID);  /* bias + sigmoid */

(Under the default narrow storage each float output is requantized to tk_scalar_t before it feeds the next layer — see examples/mlp_example.c; the snippet elides that for readability. It compiles as-is only under DTYPE=0.)

That is a full 3-layer MLP with three different bias/activation setups — exactly the heterogeneity a Transformer needs (bias-free attention projections, bias-using feed-forward). Run it: make mlp.

Training (back-propagation)

The reverse pass mirrors the forward core: tk_linear_backward computes the weight, bias, and input gradients for a dense layer; tk_sgd_step updates narrow-stored weights; tk_loss (MSE / BCE) seeds the gradient. No autograd graph — the caller drives the layers, keeping everything explicit and inspectable.

One training step is a loop: run forward, measure the loss against the target, propagate gradients backward, update the weights, repeat.

training loop: forward, loss, backward, update

Correctness is proven by a gradient check (make testbp): analytic gradients vs central finite differences, matching to <1e-2 relative error for tanh, sigmoid, relu, and gelu.

make train learns XOR — the problem a single perceptron cannot solve — end to end, in the default bfloat16:

Training XOR  (dtype=bfloat16, stochastic_rounding=1)
  epoch    0  loss 0.31523
  epoch 1000  loss 0.00035
  epoch 4000  loss 0.00007
predictions:
  (0,0) -> 0.004   (0,1) -> 0.990   (1,0) -> 0.991   (1,1) -> 0.009

That it converges in bf16 is the point of stochastic rounding (config TK_USE_STOCHASTIC_ROUNDING): under plain round-to-nearest, a weight update smaller than the storage type's precision rounds to zero and training stalls; SR rounds up/down with probability proportional to distance, so tiny updates accumulate in expectation (Gupta et al., 2015; the technique behind FP8 training on Hopper/Blackwell). It needs no fp32 master copy of the weights.

Measured — same XOR run, 4000 epochs, round-to-nearest vs SR (make DTYPE=<n> benchbp):

dtype round-to-nearest stochastic rounding
float32 0.00008 0.00008 (SR is a no-op)
bfloat16 0.01090 0.00009
tekin8 0.24862 — stalled 0.00008 — converged

In the 1-byte type, plain rounding never learns XOR; stochastic rounding does. That is the whole reason the technique exists.

Training config (all OFF by default)

Flag Effect
TK_USE_DROPOUT / TK_DROPOUT_RATE inverted dropout on activations
TK_USE_L1 / TK_L1_LAMBDA L1 weight penalty in the update
TK_USE_L2 / TK_L2_LAMBDA L2 weight penalty in the update
TK_USE_STOCHASTIC_ROUNDING SR on the weight write-back

Each sets a default; the runtime tk_optim / dropout calls override per layer. Back-propagation covers the dense layer (the shared primitive) and, since v0.2.1, the conv/pool family (conv.h, float32 — see the roadmap section); recurrent backward reuses the same pieces later.

Zero-config

Never touch config.h and you get Google's bfloat16 with bias enabled — the safe general-purpose default. Override only when you want to:

make DTYPE=4 test     # switch storage to tekin8

Install

pip install mantissa-core       # import name stays `mantissa`

The wheel ships a prebuilt libmantissa (default bfloat16) — no compiler or make needed. numpy is optional; install mantissa-core[numpy] for the zero-copy training path. Then:

from mantissa import Mantissa, STEP
tk = Mantissa()
print(tk.dtype)                # 'bfloat16'

The distribution is named mantissa-core on PyPI (the bare mantissa name is taken by an unrelated project); the Python import name is still mantissa.

Installing from an sdist (or pip install . from a checkout) compiles the C core on your machine, so a C toolchain (cc, make-grade compiler) is required for that path — wheel users need nothing. Override the storage dtype at build time with MANTISSA_DTYPE=<id> (same ids as DTYPE; default 2 = bfloat16):

pip install .                       # from a source checkout
MANTISSA_DTYPE=4 pip install .      # build the tekin8 (FP8) library instead

Download

Prebuilt shared libraries are attached to every release — built by CI for Linux, macOS, and Windows. Grab the one for your OS and load it straight from Python, no compiler or make needed:

OS file
Linux libmantissa-linux-x86_64.so
macOS libmantissa-macos-arm64.dylib
Windows libmantissa-windows-x86_64.dll
import ctypes
lib = ctypes.CDLL("./libmantissa-linux-x86_64.so")   # the file you downloaded
lib.tk_dtype_name.restype = ctypes.c_char_p
print(lib.tk_dtype_name().decode())                  # -> e.g. "bfloat16"

Or use the ctypes wrapper in python/mantissa/ (point it at the downloaded file) — pass float32 numpy arrays (or array('f')) for a zero-copy, in-place training path, ~200× faster per step than plain lists (numpy optional). For an epoch loop, Mantissa.trainer() binds the buffers' pointers once and roughly halves the per-epoch call cost again (measured 9.8 → 4.8 µs at 1030×4 — the conversion, not the C, was the cost); see docs/USAGE.md. Runnable forward + back-prop examples for Python, C++, C#, Java, JavaScript, and Rust live in clients/ — all calling the same C ABI. To build from source instead, see below.

For a full, installable client with honest benchmarks, see the sister project mantissa-perceptron — a Python perceptron classifier built on this engine.

Quick start

make test                     # tests, default bfloat16
make example                  # C perceptron
make mlp                      # mixed 3-layer MLP
make DTYPE=0 bench            # benchmark, float32
make dist && python3 python/perceptron_example.py

The Python binding is dtype-agnostic: it calls the float32 entry point, so the same script runs against any storage type the library was built for. Full API and examples in docs/USAGE.md.

Numeric landscape & roadmap

The low-precision frontier moves fast; mantissa tracks it deliberately:

  • Microscaling (MX) — OCP MX v1.0 (2023): blocks of 32 elements share one E8M0 scale, mitigating the tiny dynamic range of 4/6-bit elements. MXFP8, MXFP6, MXFP4.
  • NVFP4 — NVIDIA Blackwell (2024 hardware): 16-element blocks with an FP8 E4M3 block scale; used to pretrain LLMs at 4 bits (arXiv:2509.25149, 2025).
  • Posit / takum — tapered-precision alternatives to IEEE floats, strong for ≤8-bit inference on zero-centered weights.
  • IEEE P3109 — an emerging standard for ML arithmetic formats.

mantissa implements the element formats (E4M3, E5M2, E2M1); block-level microscaling (a shared per-block scale) is the next planned step. The conv/pool primitives landed in v0.2.1 (conv.h): im2col + register-blocked GEMM convolution, max pooling with argmax scatter, a batched dense head, fused softmax-cross-entropy and plain-f32 SGD — the full CNN training family, gradient-checked (make testconv) and benched at LeNet-5/VGG shapes (make benchconv; VGG 64→64@3×3 forward 199 GFLOP/s threaded on M4). This family is deliberately pure float32: narrow-storage conv stays on the roadmap, gated on the same block-scaling work as the 4-bit packing. Recurrent backward will reuse the same gradient and optimizer pieces.

Project layout

include/   config, dtypes + conversions, activations, ops, loss, backprop, conv, pool
src/       implementations (conv.c: the float32 CNN family — conv2d, maxpool, softmax-xent)
tests/     forward round-trip checks (7 formats) + backprop & conv gradient checks
examples/  perceptron, mixed-MLP, XOR training
bench/     GEMV + activation-dispatch benchmark, conv shapes (benchconv)
python/    ctypes binding + Python perceptron & training examples
clients/   forward + back-prop demos in C++, C#, Java, JavaScript, Rust
docs/      DESIGN.md (numerics, optimization), USAGE.md (API + examples)

License

MIT — see LICENSE. © 2024 Tekin Ertekin.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mantissa_core-0.2.10.tar.gz (131.6 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

mantissa_core-0.2.10-py3-none-win_amd64.whl (170.8 kB view details)

Uploaded Python 3Windows x86-64

mantissa_core-0.2.10-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl (134.0 kB view details)

Uploaded Python 3manylinux: glibc 2.27+ x86-64manylinux: glibc 2.28+ x86-64

mantissa_core-0.2.10-py3-none-macosx_11_0_arm64.whl (75.3 kB view details)

Uploaded Python 3macOS 11.0+ ARM64

File details

Details for the file mantissa_core-0.2.10.tar.gz.

File metadata

  • Download URL: mantissa_core-0.2.10.tar.gz
  • Upload date:
  • Size: 131.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for mantissa_core-0.2.10.tar.gz
Algorithm Hash digest
SHA256 e2afb1935bdd230193d08eec818d52bfbb273c9ee1f5b9cafb3c86783d24c3fd
MD5 beafe53eaea090082944c18ce065d926
BLAKE2b-256 fb3d072eb707b3619845cebb48d5a5c27ee68ed54a4b4f0fcd44c14115c94c9c

See more details on using hashes here.

Provenance

The following attestation bundles were made for mantissa_core-0.2.10.tar.gz:

Publisher: release.yml on tekinertekin/mantissa

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mantissa_core-0.2.10-py3-none-win_amd64.whl.

File metadata

File hashes

Hashes for mantissa_core-0.2.10-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 77739aab62b3dc7b2751e6754f757e893bd28481ea68826979d1c84135b72222
MD5 05c1c39ec7c5402d0604e6dd7ca5eae4
BLAKE2b-256 31533e344bd11d3f1fc3c85e484f18d2fa66b937d0c5c76cf6844c475f3c4aa2

See more details on using hashes here.

Provenance

The following attestation bundles were made for mantissa_core-0.2.10-py3-none-win_amd64.whl:

Publisher: release.yml on tekinertekin/mantissa

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mantissa_core-0.2.10-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for mantissa_core-0.2.10-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 e67aabb674b4956bc7b34a7b95fdd98aa496781326a353a352af4c47966948b5
MD5 36b0c382e5ff734da8a4931f359d6138
BLAKE2b-256 fcd19056e148c9c1c85cedb70d8188963830c6d753db22e717ebf56b6f84c53a

See more details on using hashes here.

Provenance

The following attestation bundles were made for mantissa_core-0.2.10-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl:

Publisher: release.yml on tekinertekin/mantissa

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mantissa_core-0.2.10-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for mantissa_core-0.2.10-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 556913a2e6c2b6130f466a419246f9a1623509b30d8b9dbcbfce2133cf8f4629
MD5 a48d62addb41c22b4b222a8b793543c6
BLAKE2b-256 b6ef40d65e224cebb6089691c4438ed1062db51fe1916d2095a33bfef77fffad

See more details on using hashes here.

Provenance

The following attestation bundles were made for mantissa_core-0.2.10-py3-none-macosx_11_0_arm64.whl:

Publisher: release.yml on tekinertekin/mantissa

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.10 This release

4 files

0.2.9

4 files

0.2.8

4 files

0.2.7

4 files

0.2.6

4 files

0.2.5

4 files

0.2.4

4 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page