Values, memory, and persistence
Example source: examples/03_plaintext_ciphertext_memory.py
This example compares level-dependent value sizes, moves live values between devices, and round-trips exact values through direct files and optional artifacts. The tutorial separates three concepts that are easy to conflate:
- a live value's current device residency;
- an exact value file at a caller-selected path;
- artifact naming and durability policy.
Run the example
Use a temporary output directory:
python examples/03_plaintext_ciphertext_memory.py \
--preset slots8192-scale40-levels7-int64 \
--levels 0,1,22
3
Keep the generated files for inspection:
python examples/03_plaintext_ciphertext_memory.py \
--preset slots8192-scale40-levels7-int64 \
--levels 0,1,2 \
--output-dir /tmp/fhelium-value-demo2
3
4
1. Compare level-dependent value size
plaintext = engine.encode(message, level=level)
ciphertext = engine.encrypt(plaintext)
print(ciphertext.limb_count)
print(ciphertext.nbytes)
print(plaintext.nbytes)2
3
4
5
6
A ciphertext owns one tensor with shape [component, *batch, active Q limb, coefficient]. This example is unbatched, so its *batch prefix is empty. As the level increases, active Q rows are removed and the dense tensor becomes smaller.
An unprepared or canonical plaintext can be much smaller than an operation-ready RNS plaintext. Compare:
canonical = engine.encode(factor_message, level=ciphertext.level)
prepared = engine.prepare_plaintext_for_multiplication(
engine.encode(factor_message, level=ciphertext.level)
)2
3
4
The prepared form pays storage for every active RNS row so that the arithmetic operation does not need to perform that conversion at request time.
2. Move a live value functionally
ciphertext_cpu = ciphertext.to("cpu")
factor_cpu = prepared.to("cpu")2
Movement follows PyTorch-style functional ownership. The returned value owns the new residency; the original CUDA value is not automatically destroyed. To release its GPU allocation, remove every live reference to the original value after synchronization and after all consumers have finished.
torch.cuda.empty_cache() concerns allocator-reserved blocks and is normally not an object-level lifecycle operation.
3. Save one exact value file
fh.save_value(
ciphertext_cpu,
"activation.safetensors",
overwrite=True,
)2
3
4
5
The core serialization API writes one versioned safetensors file. It preserves the exact value type and cryptographic metadata but deliberately owns no namespace, tenant, cache, or eviction policy.
Inspect without materializing tensors:
metadata = fh.inspect_value("activation.safetensors")Restore to the target device and require the expected type:
restored = fh.load_value(
"activation.safetensors",
expected_type=fh.Ciphertext,
device=engine.device,
)2
3
4
5
4. Reuse a named value through ArtifactStore
The repository API deliberately uses different vocabulary from the direct file codec:
| Mechanism | Write | Read |
|---|---|---|
| Caller-owned value file | fh.save_value(value, path) | fh.load_value(path) |
| Named artifact repository | store.put(name, value) | store.get(name_or_ref) |
The shortest cache-style use does not require an ArtifactRef:
from fhelium.artifacts import ArtifactStore
store = ArtifactStore(root / "artifact-store")
prepared = store.get(
"model/example/prepared-factor",
expected_type=fh.Plaintext,
device=engine.device,
)
if prepared is None:
prepared = engine.prepare_plaintext_for_multiplication(
engine.encode(factor_message, level=ciphertext.level)
)
store.put("model/example/prepared-factor", prepared)
result_ntt = engine.multiply_plaintext(
engine.coefficient_domain_to_ntt_domain(ciphertext), prepared
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
get(name) returns None only when that logical name has no current generation. Corrupt payloads, checksum failures, context mismatches, and type mismatches remain errors; they are not treated as cache misses.
Persist a live value
put accepts a supported live FHElium value and publishes one durable generation under a logical name:
activation_ref = store.put(
"requests/example/activation",
ciphertext_cpu,
)2
3
4
ciphertext_cpuremains a liveCiphertextand continues to own its tensors afterputreturns. Publication does not move, offload, mutate, or destroy the input value.activation_refis a tensor-freeArtifactRef[Ciphertext]; it contains no ciphertext tensor payload. It records the store identity, logical name, exact generation, value type, context identity, logical tensor bytes, and payload checksum.- The store now owns an independent durable payload and binds
"requests/example/activation"to that generation.
Materialize that exact generation by passing the reference back to the store:
restored_activation = store.get(
activation_ref,
expected_type=fh.Ciphertext,
device=engine.device,
)2
3
4
5
restored_activation is a reconstructed live Ciphertext on the requested device. It is distinct from both the tensor-free activation_ref and the original ciphertext_cpu object. A checked ArtifactRef never produces None: if its generation was replaced or deleted, get(activation_ref) raises StaleArtifactReferenceError.
Applications that need the current generation rather than a particular generation use the logical name:
current = store.get(
"requests/example/activation",
expected_type=fh.Ciphertext,
)
if current is None:
... # no current generation2
3
4
5
6
Value and reference lifecycle
| Operation | Live values | ArtifactRef values | Repository state |
|---|---|---|---|
activation_ref = store.put(name, ciphertext_cpu) | ciphertext_cpu remains live and unchanged | activation_ref identifies the published generation | New durable payload becomes current |
del ciphertext_cpu | Removes only that Python reference; allocator release follows ordinary PyTorch lifetime rules | activation_ref remains usable | Artifact remains current |
del activation_ref | Does not affect any live value | Only the application reference disappears | Artifact remains current and can still be found by name |
restored = store.get(activation_ref) | Creates a reconstructed live value on the requested device | activation_ref remains unchanged | Artifact remains current |
replacement_ref = store.put(name, replacement, overwrite=True) | replacement remains live | replacement_ref is current; activation_ref becomes stale | Old payload is retired after active readers finish; no history is retained |
store.delete(replacement_ref) | Existing replacement or restored values are not destroyed | replacement_ref becomes stale | Current name binding and payload are removed |
Deleting a live value, deleting an ArtifactRef, and deleting a repository artifact are therefore three independent operations. Persisting a CUDA value also does not release its CUDA allocation; remove all live CUDA references when the computation no longer needs them.
Supported persisted value state
Artifact payloads use the same exact-value schema as save_value. Supported types and their persisted state are:
| Value type | Tensor payloads | Persisted type-specific state |
|---|---|---|
Plaintext | Exactly one of message or data | Context ID, level, scale, representation, polynomial domain, modulus basis, residue representation, prime IDs, and the corresponding presence flag |
CompressedPlaintext | data and optional implicit_data | Context ID, ring dimension, compression layout/version, level, scale, domain/basis/residue state, and prime IDs |
Ciphertext | data | Context ID, level, actual scale, polynomial domain, modulus basis, residue representation, and prime IDs |
PublicKey, KeySwitchKey, RelinearizationKey, ConjugationKey | data | Concrete key type, context ID, prime IDs, and domain/basis/residue state |
RotationKey | data | The common key state plus canonical rotation_step |
SecretKey | data | The common key state; persistence requires allow_secret=True and remains unencrypted |
Each tensor is snapshotted as a dense contiguous CPU payload. Logical shape, dtype, value type, and supported cryptographic state are reconstructed; original device, stride, storage offset, and tensor aliasing are not persistent value identity. get defaults to CPU and materializes on another device only when device=... requests it.
The schema does not persist an engine, CkksConfig, encoder, application object graph, arbitrary torch.Tensor, Python containers, EvaluationKeySet, or JIT Program objects. Persist a program through its textual IR interface, persist supported component values separately, and reconstruct application-owned aggregates.
Generation replacement
replacement_ref = store.put(
"requests/example/activation",
ciphertext_cpu,
overwrite=True,
)
artifact_ciphertext = store.get(
replacement_ref,
expected_type=fh.Ciphertext,
device=engine.device,
)2
3
4
5
6
7
8
9
10
11
ArtifactStore is layered on the same typed value-file primitives. A SQLite catalog records each logical name's one current generation, while immutable store-controlled safetensors objects retain the exact payloads. The store adds typed references, collections, checksums, and transactional replacement without changing the reconstructed Ciphertext or Plaintext type.
Calling put(..., overwrite=True) publishes a new artifact ID for that name. The returned reference identifies the new current generation; any earlier reference for the same name becomes stale rather than remaining as loadable version history. Without overwrite=True, putting an already-present name raises FileExistsError.
In multiple processes, two callers may both compute a missing value before either publishes it. SQLite guarantees that at most one create succeeds. A losing caller can catch FileExistsError, discard its duplicate prepared value if appropriate, and call get(name) to use the winner.
5. Prove the restored state is usable
result = engine.rescale_to_next_level(
engine.ntt_domain_to_coefficient_domain(
engine.multiply_plaintext(
engine.coefficient_domain_to_ntt_domain(restored_ciphertext),
restored_factor,
)
)
)
decoded = engine.decrypt_message(result)2
3
4
5
6
7
8
9
Round-trip tests should evaluate a real operation, not only compare bytes. That catches lost level, polynomial domain, modulus basis, Montgomery, scale, or prime-ID metadata that a raw tensor equality check could miss.
Lifecycle summary
None of these operations implicitly destroys another live value. Residency, durability, and application cache policy remain separate decisions.
Complete runnable source
#!/usr/bin/env python3
"""Inspect residency and round-trip exact values through direct files."""
from __future__ import annotations
import argparse
from contextlib import nullcontext
from pathlib import Path
from tempfile import TemporaryDirectory
import torch
from common import (
add_engine_args,
format_bytes,
make_engine,
print_table,
small_complex_vector,
)
import fhelium as fh
from fhelium.artifacts import ArtifactStore
def _persistence_demo(
root: str | Path,
*,
engine: fh.CkksEngine,
ciphertext: fh.Ciphertext,
message: torch.Tensor,
) -> None:
factor_message = torch.full_like(message, 1.25)
canonical_factor = engine.encode(
factor_message,
level=ciphertext.level,
)
factor = engine.prepare_plaintext_for_multiplication(
engine.encode(factor_message, level=ciphertext.level)
)
canonical_bytes = canonical_factor.nbytes
ciphertext_cpu = ciphertext.to("cpu")
factor_cpu = factor.to("cpu")
assert ciphertext_cpu.is_cpu and factor_cpu.is_cpu
root = Path(root)
root.mkdir(parents=True, exist_ok=True)
activation_path = root / "activation.safetensors"
factor_path = root / "factor.safetensors"
fh.save_value(
ciphertext_cpu,
activation_path,
overwrite=True,
)
fh.save_value(
factor_cpu,
factor_path,
overwrite=True,
)
restored_ciphertext = fh.load_value(
activation_path,
device=engine.device,
expected_type=fh.Ciphertext,
)
restored_factor = fh.load_value(
factor_path,
device=engine.device,
expected_type=fh.Plaintext,
)
# ArtifactStore is a first-party repository layered on the same
# typed value-file primitives. It adds names, references, collections,
# checksums, and local durability policy without changing core values.
artifact_store = ArtifactStore(root / "artifact-store")
activation_ref = artifact_store.put(
"requests/example/activation",
ciphertext_cpu,
overwrite=True,
)
factor_ref = artifact_store.put(
"model/example/factor",
factor_cpu,
overwrite=True,
)
artifact_ciphertext = artifact_store.get(
activation_ref,
device=engine.device,
expected_type=fh.Ciphertext,
)
artifact_factor = artifact_store.get(
factor_ref,
device=engine.device,
expected_type=fh.Plaintext,
)
torch.testing.assert_close(
artifact_ciphertext.data,
restored_ciphertext.data,
)
torch.testing.assert_close(artifact_factor.data, restored_factor.data)
result = engine.rescale_to_next_level(
engine.ntt_domain_to_coefficient_domain(
engine.multiply_plaintext(
engine.coefficient_domain_to_ntt_domain(restored_ciphertext),
restored_factor,
)
)
)
decoded = engine.decrypt_message(result)[: message.numel()]
expected = (message * 1.25).to(decoded.dtype)
torch.testing.assert_close(decoded, expected, atol=3e-5, rtol=0)
print("\nResidency and value-file roundtrip:")
print(f" CUDA ciphertext: {ciphertext.device}")
print(f" offloaded ciphertext: {ciphertext_cpu.device}")
print(f" canonical factor: {format_bytes(canonical_bytes)}")
print(f" prepared factor: {format_bytes(factor.nbytes)}")
print(f" activation file: {activation_path.name}")
print(f" plaintext file: {factor_path.name}")
print(f" activation artifact: {activation_ref.name}")
print(f" plaintext artifact: {factor_ref.name}")
print(f" persistence root: {root.resolve()}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
add_engine_args(parser)
parser.add_argument("--levels", default="0,1,2")
parser.add_argument(
"--output-dir",
type=Path,
help="Keep value files under this root; otherwise use a temporary one.",
)
args = parser.parse_args()
engine = make_engine(args)
requested = [int(item) for item in args.levels.split(",") if item.strip()]
levels = [
level for level in requested if 0 <= level < engine.public_level_count
]
if not levels:
raise ValueError("--levels did not select a valid CKKS level")
message = small_complex_vector(engine.num_slots, seed=42)
rows = []
sample_ciphertext = None
for level in levels:
plaintext = engine.encode(message, level=level)
assert plaintext.data is not None
ciphertext = engine.encrypt(plaintext)
if sample_ciphertext is None:
sample_ciphertext = ciphertext
rows.append(
[
level,
ciphertext.limb_count,
tuple(ciphertext.data.shape),
format_bytes(ciphertext.nbytes),
format_bytes(plaintext.nbytes),
]
)
print_table(
["level", "Q limbs", "ciphertext shape", "ciphertext", "plaintext"],
rows,
)
assert sample_ciphertext is not None
context = (
TemporaryDirectory(prefix="fhelium-value-files-")
if args.output_dir is None
else nullcontext(args.output_dir)
)
with context as root:
_persistence_demo(
root,
engine=engine,
ciphertext=sample_ciphertext,
message=message,
)
if __name__ == "__main__":
main()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184