feat(agent): MemoryConfig::float16 stores half-precision embeddings
The setting was persisted in /meta and otherwise ignored: embeddings
were always written as f32. It now does what it says.
clawhdf5-format:
- `DatasetBuilder::with_f16_data` writes IEEE binary16 (numpy float16),
rounding to nearest-even, and `make_f16_type`.
- `clawhdf5_format::float16` holds the f32 <-> f16 conversions, the one
implementation the writer, the reader and the agent all use. Checked
against the `half` crate on 16.7M f32 values and round-trips all 65536
half values; the h5py interop tests confirm the rounding matches
numpy's bit for bit (4020 values incl. ties, subnormals, overflow).
- Reading little-endian float16 as f32 has a fast path.
clawhdf5-agent:
- A float16 store writes /memory/embeddings as half precision, and
`MemoryCache::half_precision` rounds each embedding as it enters the
cache (save, update, WAL replay, and on load of a store still f32 on
disk), so memory and file agree bit for bit and a store searches the
same before and after a reopen (tested).
- Values beyond +-65504 are refused with the new
`MemoryError::InvalidEntry` rather than stored as infinity, on every
save path; batches are all or nothing, and a rejected ephemeral entry
stays in the ephemeral tier. Breaking for exhaustive matches.
- CLI: `create --float16`. Off by default.
Measured on tank, 384-dim, six runs alternating order, medians
(search_harness --float16-study --full): at 100K the file goes from
154.0 to 80.8 MiB (-48%), checkpoint 752 -> 512 ms, open 300 -> 252 ms;
vector recall@10 against an exact scan and hybrid_search latency do not
change. At 10K open is 3 ms slower. Also a test that h5py opens a whole
agent store, f32 and float16, and decodes every dataset.
Docs: README, BENCHMARKS.md ("float16 embedding storage"), CHANGELOG
(including the h5py interop fixes in the previous commit), CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -1388,6 +1388,130 @@ with h5py.File("{path_str}", "w", libver="latest") as f:
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Half precision (float16) in both directions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Values that exercise rounding: ties, subnormals, the overflow boundary and
|
||||
/// ordinary embedding-sized components.
|
||||
fn f16_probe_values() -> Vec<f32> {
|
||||
let mut v = vec![
|
||||
0.0,
|
||||
-0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
0.5,
|
||||
1.0 + 2f32.powi(-11),
|
||||
1.0 + 3.0 * 2f32.powi(-11),
|
||||
65504.0,
|
||||
65519.0,
|
||||
65520.0,
|
||||
-70000.0,
|
||||
6.0e-8,
|
||||
3.0e-8,
|
||||
1.0e-9,
|
||||
1.0e-5,
|
||||
0.1,
|
||||
0.333_333,
|
||||
1234.567,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
];
|
||||
// A deterministic spread of embedding-like values.
|
||||
let mut x = 0x2545_F491u32;
|
||||
for _ in 0..4000 {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
v.push((x as f32 / u32::MAX as f32 - 0.5) * 0.4);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clawhdf5_writes_f16_h5py_reads() {
|
||||
skip_if_no_python!();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ours_f16.h5");
|
||||
let path_str = path.display().to_string();
|
||||
let values = f16_probe_values();
|
||||
|
||||
let mut fb = FileBuilder::new();
|
||||
fb.create_dataset("plain").with_f16_data(&values);
|
||||
fb.create_dataset("chunked")
|
||||
.with_f16_data(&values)
|
||||
.with_shape(&[values.len() as u64])
|
||||
.with_chunks(&[512])
|
||||
.with_deflate(6);
|
||||
fb.write(&path).unwrap();
|
||||
|
||||
// h5py must see a genuine float16 dataset, and our rounding must agree
|
||||
// with numpy's own float32 -> float16 conversion bit for bit.
|
||||
let input = values
|
||||
.iter()
|
||||
.map(|v| format!("{:?}", v.to_bits()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let script = format!(
|
||||
r#"
|
||||
import h5py, numpy as np
|
||||
src = np.array([{input}], dtype=np.uint32).view(np.float32)
|
||||
expected = src.astype(np.float16).view(np.uint16)
|
||||
with h5py.File("{path_str}", "r") as f:
|
||||
for name in ("plain", "chunked"):
|
||||
d = f[name]
|
||||
assert d.dtype == np.float16, (name, d.dtype)
|
||||
got = d[:].view(np.uint16)
|
||||
bad = np.nonzero(got != expected)[0]
|
||||
assert bad.size == 0, (name, bad[:5], got[bad[:5]], expected[bad[:5]])
|
||||
print("ok")
|
||||
"#
|
||||
);
|
||||
assert_eq!(run_python_output(&script), "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h5py_writes_f16_clawhdf5_reads() {
|
||||
skip_if_no_python!();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("h5py_f16.h5");
|
||||
let path_str = path.display().to_string();
|
||||
let values = f16_probe_values();
|
||||
let input = values
|
||||
.iter()
|
||||
.map(|v| format!("{:?}", v.to_bits()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
import h5py, numpy as np
|
||||
src = np.array([{input}], dtype=np.uint32).view(np.float32).astype(np.float16)
|
||||
with h5py.File("{path_str}", "w") as f:
|
||||
f.create_dataset("plain", data=src)
|
||||
f.create_dataset("chunked", data=src, chunks=(512,), compression="gzip", shuffle=True)
|
||||
f.create_dataset("big_endian", data=src.astype(">f2"))
|
||||
"#
|
||||
);
|
||||
run_python(&script);
|
||||
|
||||
let expected: Vec<u32> = values
|
||||
.iter()
|
||||
.map(|&v| clawhdf5_format::float16::round_to_f16(v).to_bits())
|
||||
.collect();
|
||||
let file = File::open(&path).unwrap();
|
||||
for name in ["plain", "chunked", "big_endian"] {
|
||||
let ds = file.dataset(name).unwrap();
|
||||
assert_eq!(
|
||||
ds.dtype().unwrap(),
|
||||
DType::Other("float16".into()),
|
||||
"{name}"
|
||||
);
|
||||
let got: Vec<u32> = ds.read_f32().unwrap().iter().map(|v| v.to_bits()).collect();
|
||||
assert_eq!(got, expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clawhdf5_writes_f32_h5py_reads() {
|
||||
// Every f32 dataset used to be unreadable by h5py ("sign bit position out
|
||||
|
||||
Reference in New Issue
Block a user