Integrate inference workloads with KVCacheStore by using the LowLevel SDK (KvcsLowLevelClient). Follow these deployment, integration, usage, and performance-tuning practices to improve performance and avoid common integration issues. All APIs and examples use LowLevel mode.
Scope
LowLevel mode does not provide server-side management capabilities such as namespace management or metadata services. You must isolate data by key prefix on the application side and manage data lifecycle through ll_batch_delete and the default LRU eviction mechanism.
This topic covers only the KV read/write capabilities provided by the LowLevel SDK, including the five operations ll_batch_put, ll_batch_get_items, ll_batch_get_into_items, ll_batch_query, and ll_batch_delete, along with the associated client configuration, error handling, and tuning methods.
Learn about KVCacheStore
KVCacheStore is a high-performance storage product designed for large model inference scenarios. Positioned as remote KVCache storage (G3.5 tier) outside the compute layer, it provides KV access interfaces and delivers hundreds of PiB storage capacity, hundreds of billions of KV entries, tens of TB/s throughput, hundreds of millions of IOPS, and sub-millisecond latency.
Before you start integration, familiarize yourself with the key capability boundaries of the product:
Capability | Metric or behavior | Impact on application design |
Single-client throughput | 40 GB/s | A single application process can sustain extremely high bandwidth. The performance bottleneck is typically on the application side (data copying and batching) rather than the storage side. |
Single-client IOPS | Millions (4K random reads) | Single-key operations cannot leverage the full product capability. You must use the Batch API to submit requests in batches. |
Single KVCacheStore capacity | Starting at 300 TiB, scalable in 300 TiB increments | Plan capacity at a coarse granularity based on total business volume rather than per-node requirements. |
Single key value size limit | Default 4 MiB, adjustable, up to 4 GiB | A single KVCache key typically represents a single prefix cache block or a single shard. Keep the size within a reasonable range. |
Access method | Compute nodes access through the local | The application process and |
Data lifecycle | LowLevel mode enables LRU eviction by default, and it cannot be disabled. | When capacity is full, data is automatically reclaimed in least-recently-used order. Written data may be evicted. Your application must be designed around the miss-and-recompute pattern. |
Typical use cases
KVCacheStore supports three typical inference-oriented use cases. You can directly adopt the corresponding integration approach or combine them.
Use case 1: Prefix caching for inference KVCache
In large model inference, identical or similar prefixes are repeatedly computed, such as shared system prompts, few-shot examples, and multi-turn conversation histories. This increases GPU computation, Time to First Token (TTFT), and inference costs.
After integrating with KVCacheStore, the inference engine can:
Use
ll_batch_queryto query whether a prefix is a cache hit.On a cache hit, use
ll_batch_get_itemsto read the KVCache and skip the corresponding computation.On a cache miss, compute normally and use
ll_batch_putto write back the results.
Deployment approach:
When the KVCache volume is large with a clear hot/cold distinction, offload historical prefix caches from GPU memory (G1/G2 tier) to KVCacheStore (G3.5 tier), freeing GPU memory for current requests.
The hit rate directly determines the benefit. We recommend including the model name, version, and prefix content hash in the key, such as
model-a/v1/prefix/<hash>. Different models should use separate prefixes to avoid data conflicts.The per-token KVCache size can be estimated using the following formula:
2 × Number of layers × Number of KV heads × Head dimension × Number of bytes per elementFor example, for Qwen2.5-72B at bf16 precision, the KVCache size per token is approximately:
2 × 80 × 8 × 128 × 2 ≈ 320 KiB/TokenThe total capacity is approximately:
KVCache size per token × Average cached sequence length × Number of prefixes to cacheAfter capacity is full, the least recently used cache entries may be subject to LRU eviction. Therefore, always maintain the complete flow of "query for cache hit → read; cache miss → recompute and write back". For implementation details, see Practice 1: Integrate prefix caching into an inference engine.
Use case 2: Cross-machine KVCache transfer and elastic scaling
During inference cluster scale-out/scale-in, failover, or Prefill/Decode disaggregated deployment, recomputing existing KVCache on new nodes increases latency in the early scale-out phase.
KVCacheStore is deployed independently of compute nodes. Nodes that mount the same KVCacheStore share a single storage pool. KV entries written by one node can be read by other nodes.
Deployment requirements:
Mount KVCacheStore on all VSCs that may carry inference workloads. Write and read operations are not constrained to specific node locations.
During scale-out/scale-in, you only need to deploy kvcs-efc on the compute node and install the SDK. No data migration or warm-up tasks are required.
After task migration, the existing KVCache remains in the storage pool (subject to default LRU eviction). New nodes can read directly by key for cache hits. Missed prefixes are automatically recomputed and written back without manual intervention.
Use case 3: Share a KVCache storage pool among multiple models and applications
Multiple models or applications can share the same KVCacheStore and isolate data through key prefixes. Each application maintains an independent read/write lifecycle without interference. For example:
model-a/v1/...model-b/v2/...application-c/...
Deployment approach:
Isolation is achieved on the application side through key prefixes (such as
model-a/...andmodel-b/...). No additional configuration is required on the KVCacheStore side.LowLevel mode enables LRU eviction (GC) by default, and it cannot be disabled: when capacity is full, data is automatically reclaimed in least-recently-used order. Applications do not need to build their own cache management logic. However, avoid mixing workloads with significantly different eviction cycles in a single KVCacheStore, because high-frequency write workloads accelerate the eviction of caches belonging to low-frequency workloads.
For workloads that require strict isolation, you can create multiple KVCacheStore instances for physical isolation through management API (instances do not share capacity or performance).
Deployment approach
The following section describes key practices during the deployment phase. For specific procedures on creating instances and mounting KVCacheStore, see Quick start.
The examples in this topic are based on KVCacheStore SDK v0.4.0. Download the SDK release package that matches your compute node's CPU architecture:
After extracting the SDK release package, you can view the complete examples in the lowlevel_demo/ directory.
Requirement level | Deployment requirement | Description |
Required | Deploy the application process and | The SDK communicates with |
Required | Deploy the compute node and KVCacheStore in close proximity, and ensure network connectivity. | Cross-region or public network access increases read/write latency. |
Recommended | Plan | During SDK initialization, a ring buffer is created in shared memory. The default configuration of 4 MiB value size limit, 256 keys/batch, and 10 workers requires approximately 10 GB of shared memory. When you increase |
Recommended | Verify the SDK and server versions before going live, and retain the version information. | Call |
SDK usage guidelines
The following guidelines cover core aspects such as client lifecycle, read/write calls, and error handling. KVCache is recomputable — any read failure should safely fall back to recomputation without affecting correctness or blocking inference — this is the starting point for all error handling design. LowLevel mode enables LRU eviction (GC) by default, and it cannot be disabled. Written data may be automatically reclaimed at any time, so a cache miss is normal semantics rather than an exception.
Importance | Guideline | Description |
Required | Reuse a single client instance within a process. Create it once during the process lifecycle and call | Client initialization creates shared memory and worker threads. Frequent creation and destruction incurs high overhead and may cause resource leaks. |
Required | Use the Batch API ( | Single-key operations cannot leverage the product's millions-level QPS capability. The Batch API amortizes per-request RTT and metadata overhead. When a single submission exceeds |
Required | Set a reasonable deadline for each read/write call (3 to 10 seconds recommended), and treat timeouts as cache misses. | KVCache is recomputable. Do not wait on timeouts — fall back to recomputation immediately to prevent slow requests from stalling the inference pipeline. On the Go side, use |
Required | Safely fall back on read failure: when | This is the core design principle for KVCache scenarios: caching is an acceleration mechanism, not a correctness dependency. Any read-path exception must not block inference. |
Recommended | Use Copy-into ( | With Copy-out, the SDK allocates memory and the caller can safely hold the result after return. With Copy-into, data is written into a caller-preallocated buffer. Reusing buffers avoids repeated memory allocation in large-value scenarios. |
Recommended | In Copy-into scenarios, provide a buffer with sufficient capacity for each key, and handle | When the buffer is smaller than the actual data, the call returns an |
Recommended | Use the iovector form to pass values during writes ( | KVCache is typically composed of tensors concatenated across multiple layers/shards. The iovector allows the SDK to gather data directly from individual memory segments, eliminating one large memory copy. |
Recommended | Design keys properly: the default maximum key length is 256 bytes. Use readable prefixes to isolate workloads (such as | Excessively long keys increase metadata overhead. Use prefixes to isolate different models and applications to prevent mutual overwriting. |
Recommended | Verify write results promptly: check the status returned by | Write failures (such as value exceeding the limit, which returns |
Recommended | Explicitly delete KVCache entries that are no longer in use through | LowLevel mode enables LRU eviction by default, and it cannot be disabled, but eviction is triggered only when capacity is full. Before that, stale cache entries continue to occupy capacity. Proactive deletion on the application side ensures data correctness (for example, when a prefix becomes invalid) and timely capacity release. |
Recommended | Before closing, ensure all in-flight requests have completed, then call |
|
Practices
The following practices provide core code that you can run directly. After extracting the SDK release package, you can view the complete code structure in the lowlevel_demo/ directory.
Practice 1: Integrate prefix caching into an inference engine
This practice covers the complete closed loop of "cache hit check → retrieve cache → miss-and-recompute with write-back", which is the core pattern for inference engine integration. The example assumes that the application already generates stable keys using the model name, model version, and prefix hash.
Python
from kvcs import KvcsLowLevelClient, KvcsError, LLGetItemOpt, LLPutItem
client = KvcsLowLevelClient(efc_socket="/var/run/kvcs/efc-grpc.sock")
def get_or_compute_prefix_cache(prefix_key: str, compute_fn):
"""Return the cached KVCache on a hit, or call compute_fn to recompute and write it back on a miss."""
# 1. Check whether the prefix is cached.
try:
qr = client.ll_batch_query([prefix_key])
if qr[0].status == "ok":
# 2. Cache hit: Retrieve the KVCache and skip the corresponding computation.
got = client.ll_batch_get_items(
[LLGetItemOpt(key=prefix_key)], deadline_s=10.0)
if got[0].status > 0: # status is the number of bytes returned on a cache hit.
return got[0].data
except KvcsError:
pass # Treat a batch query/read failure, such as a timeout, as a miss and recompute.
# 3. Cache miss: Compute as usual and write the result back for subsequent requests.
kv_cache = compute_fn()
put_results = client.ll_batch_put(
[LLPutItem(key=prefix_key, value=kv_cache)], deadline_s=10.0)
if put_results[0].status != 0:
# A write failure does not affect this inference because KVCache can be recomputed. Log the failure.
print("prefix cache write failed:", put_results[0].status)
return kv_cache
Go
func (p *PrefixCache) GetOrCompute(ctx context.Context, prefixKey string,
compute func() []byte) ([]byte, error) {
// 1. Check whether the prefix is a cache hit
qr, _ := p.client.LLBatchQuery(ctx, []string{prefixKey})
if len(qr) > 0 && qr[0].Status == "ok" {
// 2. Cache hit: retrieve the KVCache and skip the corresponding computation
got, _ := p.client.LLBatchGetItems(ctx, []kvcs.LLGetItemOpt{
{Key: prefixKey},
})
return got[0].Data, nil
return got[0].Data, nil
}
}
kvCache := compute()
kvCache := compute()
putResults, _ := p.client.LLBatchPut(ctx, []kvcs.LLPutItem{
{Key: prefixKey, Value: [][]byte{kvCache}},
})
if len(putResults) > 0 && putResults[0].Status != 0 {
}
log.Printf("prefix cache write failed: %d", putResults[0].Status)
}
return kvCache, nil
}
Rust
use std::time::Duration;
fn get_or_compute_prefix_cache(
client: &KvcsLowLevelClient,
prefix_key: &str,
compute: impl FnOnce() -> Vec<u8>,
) -> Vec<u8> {
let dl = Some(Duration::from_secs(10));
let opts = LLBatchOptions::default();
// 1. Check whether the prefix is cached.
let qr = match client.ll_batch_query(&[prefix_key], opts) {
Ok(qr) => qr,
Err(_) => Vec::new(), // Treat a query failure as a miss and recompute.
};
if !qr.is_empty() && qr[0].status == "ok" {
// 2. Cache hit: Retrieve the KVCache and skip the corresponding computation.
if let Ok(got) = client.ll_batch_get_items(
&[LLGetItemOpt { key: prefix_key }], dl, opts) {
if !got.is_empty() && got[0].status > 0 {
return got[0].data.clone().unwrap_or_default();
}
} // Treat a read failure as a miss and recompute.
}
// 3. Cache miss: Compute as usual and write the result back for subsequent requests.
let kv_cache = compute();
if let Ok(put_results) = client.ll_batch_put(
&[LLPutItem { key: prefix_key, value: &[kv_cache.as_slice()] }],
dl, opts) {
if !put_results.is_empty() && put_results[0].status != 0 {
// A write failure does not affect this inference because KVCache can be recomputed. Log the failure.
eprintln!("prefix cache write failed: {}", put_results[0].status);
}
} // A batch write failure also does not affect this inference.
kv_cache
}
Practice 2: High-throughput reads and buffer reuse
Read-path throughput is a key metric for inference integration. This practice demonstrates the Copy-into + buffer pool read pattern: preallocate buffers once and reuse them across reads to avoid allocating new memory for each read. Expand buffers on demand when insufficient.
Python
from kvcs import KvcsLowLevelClient, KvcsError, LLGetItemOpt
client = KvcsLowLevelClient(
efc_socket="/var/run/kvcs/efc-grpc.sock",
max_value_size=16 * 1024 * 1024, # Configure this based on the maximum value size of the workload.
)
# Buffer pool: Preallocate based on max_value_size and reuse across requests.
buf_pool = [bytearray(16 * 1024 * 1024) for _ in range(256)]
def read_kv_cache(keys):
bufs = buf_pool[:len(keys)]
try:
got = client.ll_batch_get_into_items(
[LLGetItemOpt(key=k) for k in keys],
bufs, deadline_s=10.0)
except (KvcsError, ValueError):
except (KvcsError, ValueError):
return [None] * len(keys)
result = [ ]
for i, r in enumerate(got):
if r.status > 0:
if r.status > 0:
else:
else:
return result
Go
// Buffer pool: Preallocate based on MaxValueSize and reuse across requests.
func (r *KVReader) ReadKVCache(ctx context.Context, keys []string) [][]byte {
items := make([]kvcs.LLGetItemOpt, len(keys))
bufs := make([][]byte, len(keys))
for i, k := range keys {
items[i] = kvcs.LLGetItemOpt{Key: k}
}
}
into, err := r.client.LLBatchGetIntoItems(ctx, items, bufs)
if err != nil {
}
}
out := make([][]byte, len(keys))
for i, it := range into {
if it.Status == kvcs.OK {
// the buffer is reused and overwritten by the next call.
out[i] = append([]byte(nil), bufs[i][:it.Size]...)
} // Treat a non-OK result as a miss and let the caller fall back.
}
return out
}
Rust
fn read_kv_cache(client: &KvcsLowLevelClient, keys: &[&str],
buffers: &mut [Vec<u8>]) -> Vec<Option<Vec<u8>>> {
let dl = Some(Duration::from_secs(10));
let opts = LLBatchOptions::default();
let items: Vec<LLGetItemOpt> =
keys.iter().map(|k| LLGetItemOpt { key: k }).collect();
// Buffer pool: Preallocate based on max_value_size and reuse across requests.
let mut bufs: Vec<&mut [u8]> =
buffers.iter_mut().map(|b| b.as_mut_slice()).collect();
match client.ll_batch_get_into_items(&items, &mut bufs, dl, opts) {
Ok(results) => results.iter().enumerate().map(|(i, r)| {
if r.status == Status::Ok {
Some(buffers[i][..r.size].to_vec())
} else {
} else {
}
}).collect(),
}).collect(),
}
}
Practice 3: Multi-file-system isolation
When the KVCacheStore server uses a pooled-disk (CPFS) backend with multiple file systems mounted, you can use the per-batch option mountpoint_index to specify the target file system for the current batch of operations (0 is the default file system), achieving physical isolation between workloads. This capability takes effect only on pooled-disk backends. For direct-disk backends, this parameter is ignored, and writes go to the default disk without errors.
from kvcs import LLBatchOptions, LLPutItem
# Write to the file system dedicated to model-b (mountpoint_index=1).
results = client.ll_batch_put(
[LLPutItem(key="model-b/layer-0/prefix-x", value=b"...")],
deadline_s=10.0,
opts=LLBatchOptions(mountpoint_index=1),
)
On the Go side, pass this through kvcs.WithMountpointIndex(1). On the Rust side, construct LLBatchOptions { mountpoint_index: 1 }. Only on pooled-disk backends, when the specified index is not configured in the server-side mount points, the batch returns ENODEV.
Performance tuning
The following tuning items are presented in the order of "basic configuration → benchmark validation → asynchronous write-back → metrics monitoring". All configurations take effect at client initialization. You must recreate the client after making changes.
1. Concurrency
get_workers, set_workers, and simple_workers control the number of workers for read, write, and simple operations (Query/Delete) respectively, with default values of 4, 4, and 2.
Start with the default values and adjust incrementally based on your workload:
Estimate the number of workers as "peak QPS × average request latency (in seconds)".
For read-intensive workloads, increase
get_workersfirst.For write-intensive workloads, increase
set_workersfirst.After increasing the number of workers, reassess shared memory and thread scheduling overhead.
Adjust only one category of parameters at a time, and record the changes in throughput, latency, and error rate.
2. Batch size and memory configuration
max_keys_per_batch is the capacity parameter for the SDK internal ring and shared memory. When a single submission exceeds this value, the SDK automatically splits it.
max_value_size defaults to 4 MiB, with a maximum of 4 GiB. When a value exceeds the limit, the write result returns E2BIG per key.
Tuning recommendations:
When values are generally smaller than 1 MiB, keep the default of 4 MiB.
When values approach or exceed 4 MiB, increase
max_value_sizeand reducemax_keys_per_batchor the number of workers accordingly to avoid exponential growth of shared memory.Prepare buffers based on the actual value size distribution. Avoid using the maximum specification for all requests.
3. Performance testing
Use a benchmark tool to verify throughput and latency at your production scale. The kvcs-bench provided in the repository supports multi-node benchmark orchestration. You can submit various worker/batch-size parameter matrices in a single run and aggregate steady-state metrics:
cd kvcs-test/benchmark
./kvcs-bench \
--environment /path/to/environment.json \
batch --ops get --workers 2,4,8,16 --batch-sizes 128The benchmark should cover: typical value sizes, read/write ratios, peak concurrency, and 1.5× peak concurrency. Clean up benchmark data after testing to avoid occupying production capacity.
4. Asynchronous write-back
Practice 1 uses synchronous write-back, which is simple and reliable. In production environments, the write-back data volume after prefill can be large (a single long-sequence request can reach MB to GB level), and waiting synchronously adds storage latency directly to inference latency. Since KVCache is recomputable, a write-back failure does not affect the current inference — it only affects future hit rate. Therefore, asynchronous write-back is recommended for production environments to move write-back off the inference critical path. A correct implementation must satisfy three key requirements:
buffer ownership until write-back completes: The SDK holds a zero-copy reference to the value, and the buffer must not be reused before
ll_batch_putreturns. The inference engine's KV block pool recycles buffers. During write-back, the buffer must remain exclusively owned. The safest approach is to transfer buffer ownership to the write-back thread, which returns the buffer after put completes.bounded queue + drop policy: The write-back queue must have a capacity limit. When backlogged, drop new writes directly — this only reduces the hit rate without affecting correctness. An unbounded queue will exhaust memory during storage jitter.
per-key status tracked in monitoring: After switching to asynchronous mode, write results have no synchronous consumer. Failures accumulate silently. You must feed write failure rates and drop rates into monitoring (see runtime metrics). Otherwise, a decline in hit rate will be impossible to diagnose.
Python example (covering all four aspects: buffer handoff, bounded queue, drop policy, and counting):
import queue
import threading
write_queue = queue.Queue(maxsize=64) # Bounded queue: maximum backpressure.
stats = {"ok": 0, "fail": 0}
def writer_main():
"""Use a dedicated write-back thread so that put does not occupy an inference thread."""
while True:
item = write_queue.get()
if item is None: # Exit signal.
return
key, kv_cache = item
results = client.ll_batch_put(
[LLPutItem(key=key, value=kv_cache)], deadline_s=1.0)
if results[0].status == 0:
stats["ok"] += 1
else:
stats["fail"] += 1 # Monitor failures because asynchronous writes have no synchronous consumer.
# After put returns, the kv_cache reference can be safely released.
threading.Thread(target=writer_main, daemon=True).start()
def put_async(key: str, kv_cache: bytes):
def put_async(key: str, kv_cache: bytes):
try:
write_queue.put_nowait((key, kv_cache))
except queue.Full:
passFor small KV entries, prototyping phases, or when there is a natural scheduling gap between prefill and decode, keep the synchronous write-back from Practice 1. There is no need to force asynchronous mode. For high-concurrency, long-sequence production scenarios, asynchronous write-back is recommended.
5. Runtime metrics
Enable enable_metrics during initialization and use get_metrics() / get_metrics_text() (or GetMetrics() / GetMetricsText() in Go) to obtain SDK runtime metrics for validating tuning results and production monitoring. Metrics include: success/cache miss/error counts by operation type (put/get/delete/exists), cumulative key counts and byte counts, batch latency distribution, in-flight batch count and in-flight key count, and client-side latency breakdown by phase.
The SDK does not output QPS directly. You can calculate it by taking the difference between two snapshot counts. In LowLevel direct-connect mode, control plane (Redis) metrics are always 0. Focus on data plane metrics instead.
Troubleshooting
Symptom | Possible cause | Recommended action |
Client initialization fails or the first call returns an error | kvcs-efc is not deployed or not ready; the socket path does not exist | Run |
Read/write operations return permission-related errors | The application process and kvcs-efc are running under different users | Switch the application process to the same operating system user as kvcs-efc |
Write result status is | The value exceeds the | Increase |
Copy-into read returns an | The preallocated buffer is smaller than the actual data | Allocate the buffer at the |
A batch with the specified | The specified index is not configured in the server-side mount points | Verify the number of mount points and use 0 (default file system) instead |
| Key mismatch (inconsistent prefix/encoding); data has been automatically evicted by LRU GC | Check the key construction logic. LowLevel mode enables LRU eviction by default, and it cannot be disabled. Data may have been reclaimed. Handle this as a miss-and-recompute scenario. |
Version information | libkvcs is not an official build artifact | Reinstall the SDK through the official release package or |
Abnormal behavior after upgrading the SDK | The application process is still loading the old version of libkvcs | Restart the application process and verify that the |