feat(agent): new stores default to float16 embeddings
MemoryConfig::float16 now defaults to true for new stores, on measurement: on the full LongMemEval haystack with real MiniLM embeddings every retrieval metric matched f32 (previous commit), and at 100K the file is 48% smaller with faster checkpoints and opens. Existing stores are unaffected: every agent store has recorded `float16 = false` in /meta and keeps it. A test opens the v2.5.0 fixture, saves and checkpoints, and checks the embeddings are still f32 with the old rows bit-identical; another checks a new store is float16. CLI: `create --f32` opts out; like `--f32-index` it only ever switches the default off. `--float16` is still accepted and now a no-op. Values beyond +-65504 are refused, so f32 remains the choice for unnormalised vectors — the upgrade note says so. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
+17
-6
@@ -11,12 +11,18 @@
|
|||||||
each checkpoint, so it becomes readable at its next checkpoint on this
|
each checkpoint, so it becomes readable at its next checkpoint on this
|
||||||
version; other files with `f32` or empty datasets need rewriting. Details in
|
version; other files with `f32` or empty datasets need rewriting. Details in
|
||||||
`docs/known-issues.md`.
|
`docs/known-issues.md`.
|
||||||
- **`MemoryConfig::float16` now does what it says.** It was persisted and
|
- **New stores store embeddings as half precision by default.**
|
||||||
otherwise ignored; embeddings were always stored as `f32`. A store created
|
`MemoryConfig::float16` was persisted and otherwise ignored; it now writes
|
||||||
with it on now writes half-precision embeddings (48% smaller files) and
|
`float16` embeddings (48% smaller files at 100K) and rounds each embedding
|
||||||
rounds embeddings to half precision as they are saved. A store that already
|
to half precision as it is saved — and it defaults to `true` for new
|
||||||
had `float16 = true` rounds its embeddings when next opened and writes them
|
stores. On the full LongMemEval haystack with real MiniLM embeddings every
|
||||||
as `float16` at its next checkpoint. Off by default.
|
retrieval metric matched `f32`. **Existing stores are unaffected**: every
|
||||||
|
agent store has recorded `float16 = false`, and keeps it (a v2.5.0 fixture
|
||||||
|
guards this). A store that already had `float16 = true` rounds its
|
||||||
|
embeddings when next opened and writes them as `float16` at its next
|
||||||
|
checkpoint. Opt out with `float16 = false` or `create --f32`; the CLI's
|
||||||
|
`--float16` is still accepted and now a no-op. Values beyond ±65504 are
|
||||||
|
refused, so keep `f32` for unnormalised vectors.
|
||||||
- **Breaking:** `MemoryError` gained `InvalidEntry`, returned when a
|
- **Breaking:** `MemoryError` gained `InvalidEntry`, returned when a
|
||||||
`float16` store is given an embedding value beyond ±65504. Exhaustive
|
`float16` store is given an embedding value beyond ±65504. Exhaustive
|
||||||
matches need the new arm.
|
matches need the new arm.
|
||||||
@@ -123,6 +129,11 @@
|
|||||||
now an error.
|
now an error.
|
||||||
|
|
||||||
### Defaults
|
### Defaults
|
||||||
|
- `clawhdf5-agent`: `MemoryConfig::float16` defaults to `true` for new stores,
|
||||||
|
measured rather than assumed: identical LongMemEval retrieval on real
|
||||||
|
embeddings, 48% smaller files and faster checkpoints and opens at 100K.
|
||||||
|
`clawhdf5-cli create --f32` opts out; like `--f32-index`, it only ever
|
||||||
|
switches the default off.
|
||||||
- `clawhdf5-agent`: `MemoryConfig::quantized_index` defaults to `true` for new
|
- `clawhdf5-agent`: `MemoryConfig::quantized_index` defaults to `true` for new
|
||||||
stores. The reason it had been off — that int8 search was slower on ARM —
|
stores. The reason it had been off — that int8 search was slower on ARM —
|
||||||
did not survive measurement (see Corrections). Stores that predate the
|
did not survive measurement (see Corrections). Stores that predate the
|
||||||
|
|||||||
@@ -87,10 +87,12 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
|
|||||||
`export` do). An unreadable WAL (torn header, bad magic) is quarantined to
|
`export` do). An unreadable WAL (torn header, bad magic) is quarantined to
|
||||||
`<store>.h5.wal.corrupt-<ts>` rather than blocking `open()`; a WAL with an
|
`<store>.h5.wal.corrupt-<ts>` rather than blocking `open()`; a WAL with an
|
||||||
unknown *newer* version still fails and is left untouched.
|
unknown *newer* version still fails and is left untouched.
|
||||||
- `MemoryConfig::float16` (off by default, persisted; CLI `create --float16`)
|
- `MemoryConfig::float16` (**on by default** for new stores, persisted;
|
||||||
writes `/memory/embeddings` as IEEE half precision (48% smaller file at
|
existing stores keep their recorded `false` — guarded by the v2.5.0
|
||||||
100K, same recall). `MemoryCache::half_precision` rounds each embedding as
|
fixture in `tests/float16_store.rs`; CLI opt-out is `create --f32`) writes
|
||||||
it enters the cache (push, update, WAL replay, and on load of a store still
|
`/memory/embeddings` as IEEE half precision (48% smaller file at 100K;
|
||||||
|
LongMemEval with real MiniLM embeddings identical to f32).
|
||||||
|
`MemoryCache::half_precision` rounds each embedding as it enters the cache (push, update, WAL replay, and on load of a store still
|
||||||
`f32` on disk), so memory and file agree bit for bit; the conversions live
|
`f32` on disk), so memory and file agree bit for bit; the conversions live
|
||||||
in `clawhdf5_format::float16` and must stay the single implementation.
|
in `clawhdf5_format::float16` and must stay the single implementation.
|
||||||
Values beyond ±65504 are `MemoryError::InvalidEntry`. Interop: every file
|
Values beyond ±65504 are `MemoryError::InvalidEntry`. Interop: every file
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ breaking change, are in [CHANGELOG.md](CHANGELOG.md).
|
|||||||
fix themselves at their next checkpoint. See
|
fix themselves at their next checkpoint. See
|
||||||
[docs/known-issues.md](docs/known-issues.md).
|
[docs/known-issues.md](docs/known-issues.md).
|
||||||
- `MemoryConfig::float16` now stores half-precision embeddings (it was
|
- `MemoryConfig::float16` now stores half-precision embeddings (it was
|
||||||
ignored): 48% smaller files at the same recall.
|
ignored), and is on by default for new stores: 48% smaller files, and
|
||||||
|
identical LongMemEval retrieval on real embeddings.
|
||||||
- `HDF5Memory::search` with `SearchOptions`: filter by source channel (exact
|
- `HDF5Memory::search` with `SearchOptions`: filter by source channel (exact
|
||||||
filtered top-k, never slower than unfiltered), and opt-in re-ranking and
|
filtered top-k, never slower than unfiltered), and opt-in re-ranking and
|
||||||
confidence rejection, which used to be OpenClaw-only.
|
confidence rejection, which used to be OpenClaw-only.
|
||||||
@@ -256,8 +257,9 @@ retrieval recall reported as QA accuracy typically overstates by 20–30 points.
|
|||||||
| 10K | 17.0 MB | 1.7 KB | 2.7 MB (6.2x) |
|
| 10K | 17.0 MB | 1.7 KB | 2.7 MB (6.2x) |
|
||||||
| 100K | 169.8 MB | 1.7 KB | 26.9 MB (6.2x) |
|
| 100K | 169.8 MB | 1.7 KB | 26.9 MB (6.2x) |
|
||||||
|
|
||||||
With `MemoryConfig::float16` the embeddings take half the space: an agent
|
These figures are `f32` embeddings. New agent stores default to
|
||||||
store of 100K × 384 records is 80.8 MiB instead of 154.0.
|
`MemoryConfig::float16`, which halves them: 100K × 384 records take 80.8 MiB
|
||||||
|
instead of 154.0.
|
||||||
|
|
||||||
**In memory** — a store reopened from disk, 384-dim `f32`, measured with a
|
**In memory** — a store reopened from disk, 384-dim `f32`, measured with a
|
||||||
counting allocator ([BENCHMARKS.md § Memory footprint](BENCHMARKS.md#memory-footprint)):
|
counting allocator ([BENCHMARKS.md § Memory footprint](BENCHMARKS.md#memory-footprint)):
|
||||||
@@ -610,12 +612,14 @@ setting existed keep their `f32` index; opt out for new stores with
|
|||||||
`quantized_index = false` or `clawhdf5-cli create --f32-index`. See
|
`quantized_index = false` or `clawhdf5-cli create --f32-index`. See
|
||||||
[BENCHMARKS.md § Quantising the index copy](BENCHMARKS.md#quantising-the-index-copy-quantized_index).
|
[BENCHMARKS.md § Quantising the index copy](BENCHMARKS.md#quantising-the-index-copy-quantized_index).
|
||||||
|
|
||||||
`MemoryConfig::float16` (off by default; CLI `create --float16`) stores the
|
`MemoryConfig::float16` (**on by default** for new stores) stores the
|
||||||
embeddings on disk as IEEE half precision (numpy `float16`): at 100K × 384 the
|
embeddings on disk as IEEE half precision (numpy `float16`): at 100K × 384 the
|
||||||
file drops from 154 to 81 MiB, checkpoints and opens get faster, and vector
|
file drops from 154 to 81 MiB, checkpoints and opens get faster, and on the
|
||||||
recall and search latency do not change. Embeddings are rounded as they are
|
full LongMemEval haystack with real MiniLM embeddings every retrieval metric
|
||||||
saved, so the store searches the same before and after a reopen; values must
|
matches `f32`. Embeddings are rounded as they are saved, so the store searches
|
||||||
lie within ±65504. See
|
the same before and after a reopen; values must lie within ±65504. Existing
|
||||||
|
stores keep their setting. Opt out with `float16 = false` or
|
||||||
|
`clawhdf5-cli create --f32` — e.g. for unnormalised vectors. See
|
||||||
[BENCHMARKS.md § float16 embedding storage](BENCHMARKS.md#float16-embedding-storage-memoryconfigfloat16).
|
[BENCHMARKS.md § float16 embedding storage](BENCHMARKS.md#float16-embedding-storage-memoryconfigfloat16).
|
||||||
|
|
||||||
### `clawhdf5-format`
|
### `clawhdf5-format`
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ pub struct MemoryConfig {
|
|||||||
/// so search results are the same before and after a reopen. Values must
|
/// so search results are the same before and after a reopen. Values must
|
||||||
/// lie within ±65504; a save outside that is `MemoryError::InvalidEntry`.
|
/// lie within ±65504; a save outside that is `MemoryError::InvalidEntry`.
|
||||||
/// Fixed when the store is created (persisted in `/meta`).
|
/// Fixed when the store is created (persisted in `/meta`).
|
||||||
|
///
|
||||||
|
/// **On by default for new stores**: on the full LongMemEval haystack with
|
||||||
|
/// real MiniLM embeddings every retrieval metric matched `f32`, and at
|
||||||
|
/// 100K records the file is 48% smaller (`BENCHMARKS.md`). Existing
|
||||||
|
/// stores keep the setting they were created with. Set it to `false` for
|
||||||
|
/// full-precision embeddings, e.g. for unnormalised vectors that may
|
||||||
|
/// exceed the half-precision range.
|
||||||
pub float16: bool,
|
pub float16: bool,
|
||||||
pub compression: bool,
|
pub compression: bool,
|
||||||
pub compression_level: u32,
|
pub compression_level: u32,
|
||||||
@@ -191,7 +198,7 @@ impl MemoryConfig {
|
|||||||
embedding_dim,
|
embedding_dim,
|
||||||
chunk_size: 512,
|
chunk_size: 512,
|
||||||
overlap: 50,
|
overlap: 50,
|
||||||
float16: false,
|
float16: true,
|
||||||
compression: false,
|
compression: false,
|
||||||
compression_level: 0,
|
compression_level: 0,
|
||||||
compact_threshold: 0.3,
|
compact_threshold: 0.3,
|
||||||
|
|||||||
@@ -206,3 +206,55 @@ fn wal_replay_rounds_like_a_live_save() {
|
|||||||
assert_eq!(recovered.count(), 30);
|
assert_eq!(recovered.count(), 30);
|
||||||
assert_eq!(search_bits(&mut recovered, 77), live);
|
assert_eq!(search_bits(&mut recovered, 77), live);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_stores_default_to_float16() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("default.h5");
|
||||||
|
let mut m = HDF5Memory::create(MemoryConfig::new(path.clone(), "agent", DIM)).unwrap();
|
||||||
|
assert!(m.config().float16);
|
||||||
|
m.save_batch((0..10).map(entry).collect()).unwrap();
|
||||||
|
drop(m);
|
||||||
|
assert_eq!(embeddings_dtype_and_values(&path).0, "Other(\"float16\")");
|
||||||
|
assert!(HDF5Memory::open(&path).unwrap().config().float16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_existing_f32_store_stays_f32() {
|
||||||
|
// Written by the v2.5.0 CLI, with `float16 = 0` in /meta (every agent
|
||||||
|
// store has recorded it). Flipping the default for new stores must not
|
||||||
|
// reach back and round an existing store's embeddings.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("legacy.h5");
|
||||||
|
std::fs::copy(
|
||||||
|
concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixtures/store_v2_5_0.h5"
|
||||||
|
),
|
||||||
|
&path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let before = embeddings_dtype_and_values(&path);
|
||||||
|
assert_eq!(before.0, "F32");
|
||||||
|
|
||||||
|
let mut m = HDF5Memory::open(&path).unwrap();
|
||||||
|
assert!(!m.config().float16, "an old store must reopen as f32");
|
||||||
|
let dim = m.config().embedding_dim;
|
||||||
|
let odd: Vec<f32> = (0..dim).map(|i| 0.1 + i as f32 * 1e-4).collect();
|
||||||
|
m.save_batch(vec![MemoryEntry {
|
||||||
|
chunk: "added after the upgrade".into(),
|
||||||
|
embedding: odd.clone(),
|
||||||
|
source_channel: "test".into(),
|
||||||
|
timestamp: 1.0,
|
||||||
|
session_id: "s".into(),
|
||||||
|
tags: String::new(),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
drop(m);
|
||||||
|
|
||||||
|
// Checkpointed: still f32, the old rows untouched and the new one exact.
|
||||||
|
let (dtype, values) = embeddings_dtype_and_values(&path);
|
||||||
|
assert_eq!(dtype, "F32");
|
||||||
|
assert_eq!(&values[..before.1.len()], before.1.as_slice());
|
||||||
|
assert_eq!(&values[before.1.len()..], odd.as_slice());
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,10 +36,13 @@ enum Commands {
|
|||||||
/// Accepted for compatibility; int8 is now the default
|
/// Accepted for compatibility; int8 is now the default
|
||||||
#[arg(long, hide = true, conflicts_with = "f32_index")]
|
#[arg(long, hide = true, conflicts_with = "f32_index")]
|
||||||
quantized_index: bool,
|
quantized_index: bool,
|
||||||
/// Store embeddings on disk as IEEE half precision (float16): half
|
/// Store embeddings as full-precision f32 instead of the default
|
||||||
/// the bytes, about three significant digits; values must lie within
|
/// half precision (float16: half the bytes, about three significant
|
||||||
/// ±65504
|
/// digits, values within ±65504)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
f32: bool,
|
||||||
|
/// Accepted for compatibility; float16 is now the default
|
||||||
|
#[arg(long, hide = true, conflicts_with = "f32")]
|
||||||
float16: bool,
|
float16: bool,
|
||||||
},
|
},
|
||||||
/// Save a memory entry (reads JSON from stdin or --json)
|
/// Save a memory entry (reads JSON from stdin or --json)
|
||||||
@@ -107,11 +110,16 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
wal,
|
wal,
|
||||||
f32_index,
|
f32_index,
|
||||||
quantized_index: _,
|
quantized_index: _,
|
||||||
float16,
|
f32,
|
||||||
|
float16: _,
|
||||||
} => {
|
} => {
|
||||||
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
|
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim);
|
||||||
config.wal_enabled = wal;
|
config.wal_enabled = wal;
|
||||||
config.float16 = float16;
|
// As with --f32-index: only ever switch the library default off.
|
||||||
|
if f32 {
|
||||||
|
config.float16 = false;
|
||||||
|
}
|
||||||
|
let config_float16 = config.float16;
|
||||||
// Only ever switch *off* the library default: assigning the flag
|
// Only ever switch *off* the library default: assigning the flag
|
||||||
// outright would force every CLI-created store back to f32 unless
|
// outright would force every CLI-created store back to f32 unless
|
||||||
// the caller knew to ask for int8.
|
// the caller knew to ask for int8.
|
||||||
@@ -127,7 +135,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"embedding_dim": dim,
|
"embedding_dim": dim,
|
||||||
"wal_enabled": wal,
|
"wal_enabled": wal,
|
||||||
"quantized_index": config_quantized,
|
"quantized_index": config_quantized,
|
||||||
"float16": float16,
|
"float16": config_float16,
|
||||||
"count": mem.count(),
|
"count": mem.count(),
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&j)?);
|
println!("{}", serde_json::to_string_pretty(&j)?);
|
||||||
|
|||||||
Reference in New Issue
Block a user