Skip to content

Compress the embedding table to SFP at load time - #993

Open
Mikyx-1 wants to merge 4 commits into
google:devfrom
Mikyx-1:sfp-embedding-dev
Open

Compress the embedding table to SFP at load time#993
Mikyx-1 wants to merge 4 commits into
google:devfrom
Mikyx-1:sfp-embedding-dev

Conversation

@Mikyx-1

@Mikyx-1 Mikyx-1 commented Aug 27, 2026

Copy link
Copy Markdown

Summary

  • add --sfp_embedding to convert the input embedding from F32/BF16 to SFP while loading
  • use two-pass, 4 MiB chunked conversion to bound temporary memory and preserve the tensor scale
  • disable automatic memory mapping when conversion is requested; explicit --map 1 still takes precedence and warns
  • validate the source blob size before chunked reads
  • document that logits MatMul speedups apply to tied input/output embeddings, while untied models retain the memory saving
  • add focused tests for F32/BF16 conversion, scale propagation, partial chunks, invalid blob sizes, and mode selection

This implements the embedding-compression approach discussed in #164. Existing SFP read and MatMul paths require no changes after loading.

Results

Measured on an M2 with four workers using 2.0-2b-pt-sfp.sbs in read mode:

Metric Before After Change
Gen.EmbeddingMatmul 654 us/token 468 us/token -28.5%
Decode 11.21 tok/s 12.42 tok/s +10.8%
Maximum RSS 3482 MiB 2928 MiB -554 MiB
Cross entropy/byte 1.372889 1.368894 no degradation

Testing

  • cmake --build build --target weights_test -j4
  • ctest --test-dir build -R '^WeightsTest\.' --output-on-failure
  • cmake --build build --target gemma -j4
  • CLI smoke test with Gemma 3 270M, both automatic mapping and explicit --map 1

`c_embedding` is capped at 16 bits by `.min_size = Type::kBF16` in
tensor_info.cc, so it stays BF16 even in an SFP model where every other
large tensor is 8-bit. For 2.0-2b-pt-sfp.sbs that single 256000x2304
tensor is 1.18 GB, 37% of the file.

Add `--sfp_embedding` (default off) to compress it to SFP while loading,
as suggested by google#164. This halves its footprint and, for
models with tied input/output embeddings, the weight bandwidth of the
per-token logits MatMul. Models with a separate output head receive only
the memory benefit. The read paths already handle SFP: `EmbedToken`
seeks by `Stride()`, and `CallMatMul` dispatches to matmul_static_sfp.

`MakeBatches` reads directly into destination rows, so flagged tensors
bypass it and are read via `ReadAllToSFP`. That reads 4 MiB row chunks
rather than staging the whole tensor, which would raise peak RSS by more
than the conversion saves. The file is read twice because SFP encodes a
limited range of magnitudes, hence the per-tensor scale must be known
before encoding; the second read comes from the OS cache.

Conversion requires owned memory, so an explicit SFP request disables
automatic mapping. An explicit `--map=1` still takes precedence and
warns that SFP embedding conversion is ignored. Validate the source blob
size before chunked reads so inconsistent metadata cannot cross into an
adjacent blob.

Add focused tests for F32 and BF16 conversion, scale propagation, final
partial chunks, malformed blob sizes, and mapping-mode selection.

Measured on an M2 (4 workers), 2.0-2b-pt-sfp.sbs, Mode::kRead:
  Gen.EmbeddingMatmul  654 -> 468 us/token   (-28.5%)
  decode               11.21 -> 12.42 tok/s  (+10.8%, median of 3)
  max RSS              3482 -> 2928 MiB      (-554 MiB)
  cross entropy/byte   1.372889 -> 1.368894  (no degradation)

Startup does not regress: ReadBatches drops more than ReadAllToSFP adds,
because MakeBatches issues row-wise I/O for this padded 256000-row
tensor.

@jan-wassenberg jan-wassenberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, thanks! Two minor suggestions.

Comment thread gemma/weights.cc Outdated
buffers.Decompress(tensor, reader, begin, end);

float maxabs = 0.0f;
for (size_t i = 0; i < (end - begin) * cols; ++i) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this worth vectorizing? It's nice to minimize startup time. Recommend two separate vectors and then ReduceMax(d, Max(v1, v2)).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a075d4e. MaxAbs now uses two independent Highway vector accumulators and combines them with ReduceMax(df, Max(max0, max1)); the remaining vector and scalar tail are handled explicitly. On a Gemma 3 4B-sized BF16 chunk (819 x 2560 floats), the loop benchmark improved from 2.110 ms to 0.270 ms (~7.8x), with a bit-identical maximum. Thanks!

Comment thread gemma/weights_test.cc Outdated
}

template <>
float SourceValue(const BF16 value) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both conversions can just be hwy::ConvertScalarTo.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4b49a8f. Both SourceValue and MakeSourceValue now use hwy::ConvertScalarTo, so the explicit BF16 specializations are gone. Thanks!

@jan-wassenberg jan-wassenberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, congrats on the speedup. Interesting the compiler was not able to autovectorize this effectively.
Note that weights.cc does not yet use dynamic dispatch, but that's probably fine, there's only light compute happening here.

One more minor nit for readability:

Comment thread gemma/weights.cc Outdated
Chunk<T> buffers(cols, rows_per_chunk);
const float* raw = buffers.Decompress(tensor, reader, begin, end);

using DF = hwy::HWY_NAMESPACE::ScalableTag<float>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add namespace hn = hwy::HWY_NAMESPACE; (typically at namespace scope), then write hn::Zero to shorten the code :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f3407af. Added namespace hn = hwy::HWY_NAMESPACE; at namespace scope and used the hn:: shorthand for the Highway operations, including hn::Zero. Also ran clang-format and verified all four weights_test cases pass. Thanks for giving me feedback!

@jan-wassenberg jan-wassenberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for updating!

@jan-wassenberg jan-wassenberg added the copybara-import Trigger Copybara for merging pull requests label Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

copybara-import Trigger Copybara for merging pull requests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants