Skip to content

Commit 3b2a0a7

Browse files
authored
Merge pull request #17 from aerospike-community/lyndon/l2-batched-perf
Add native Aerospike L2 connector
2 parents 2e2630c + 7610f9b commit 3b2a0a7

41 files changed

Lines changed: 2535 additions & 207 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ venv/
1212
.env
1313
.envrc
1414
.aerospike-ci.env
15+
.redis-bench.env
1516
.pytest_cache/
1617
.mypy_cache/
1718
.ruff_cache/
@@ -25,5 +26,7 @@ htmlcov/
2526
.claude/
2627
# Local LMCache dev clone for L2 integration tests / CI layout (see README).
2728
LMCache/
29+
.deps/
2830
benchmarks/results/
31+
benchmarks/l2/results/
2932
benchmarks/l2/.env.local

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ For Aerospike client/modeling rules, use [aerospike/agent-skills](https://github
1818
| ----- | ------- | ------------------ |
1919
| 1 | `ConnectorAdapter` + `RemoteConnector` (Python) | **Implemented** |
2020
| 2 | `StoragePluginInterface`, `L2AdapterInterface` | **Implemented** (`storage_plugin.py`, `l2_plugin.py`) |
21-
| 3 | C++ `ConnectorBase` / `libaerospike` | Architectural in `DESIGN.md` only |
21+
| 3 | C++ `ConnectorBase` / `libaerospike` | **Implemented** (`csrc/aerospike/`, `native_connector.py`; build via `scripts/build_libaerospike.sh`) |
2222

2323
Stay inside Phase 1 unless the user explicitly expands scope.
2424

@@ -37,9 +37,10 @@ docker/ scripts/
3737
| ----- | ------- | ----- |
3838
| Preflight (S0) | `python scripts/preflight.py` | LMCache + Aerospike client symbols |
3939
| Unit | `pytest tests/unit -q` | No network |
40-
| Integration | `./scripts/ci_integration_install.sh` then `./scripts/start_aerospike_ce.sh` and `pytest tests/integration -q` | Live CE + LMCache `dev` for L2 E2E (mirrors CI) |
40+
| Integration | `./scripts/start_aerospike_ce.sh` then `pytest tests/integration -q` | Live CE |
4141
| Ecosystem bench | `pip install -r benchmarks/requirements.txt` then `python benchmarks/run.py --profile smoke` | Not in CI by default |
42-
| L2 bench | `./scripts/setup_l2_bench.sh` then `./benchmarks/l2/run.sh` (LMCache `dev` + live CE) | Not in CI by default |
42+
| L2 bench | `./scripts/setup_l2_bench.sh` (builds `.deps/` C client + `_native`); `./benchmarks/l2/compare.sh` | Not in CI by default |
43+
| Native integration | `RUN_NATIVE=1 pytest tests/integration/test_native_connector_e2e.py -q` | After `build_libaerospike.sh` + CE |
4344
| Micro bench | `RUN_BENCH=1 pytest benchmarks/micro --benchmark-only` | FakeClient only |
4445

4546
Pinned versions: `IMPLEMENTATION_PLAN.md` §0.2.

DESIGN.md

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
# LMCache Aerospike Backend - Design Document
22

3-
**Status:** v0.2 — Phase 1 implemented (see `main` and `IMPLEMENTATION_PLAN.md`)
3+
**Status:** v0.3 — Phase 1 and Phase 2 implemented; Phase 3 native connector in progress (see `main` and `IMPLEMENTATION_PLAN.md`)
44
**Audience:** Engineers implementing and reviewing an Aerospike storage backend for [LMCache](https://github.com/LMCache/LMCache).
55
**Scope:** A multi-phase plan delivering an Aerospike-backed remote KV-cache tier for LMCache, anchored to LMCache's `RemoteConnector` plugin contract and a CE-only, adaptive-sharded Aerospike data model tuned for ~4 MiB chunks.
66

7-
> **v0.2 reconciliation (verified against upstream LMCache `dev` and the official Aerospike Python client).** This revision corrects the design against the actual contracts before implementation. The companion build guide is `IMPLEMENTATION_PLAN.md`. Key changes:
7+
> **v0.3 reconciliation (verified against upstream LMCache `dev`, LMCache native RESP, and the official Aerospike clients).** This revision corrects the design against the actual contracts before implementation. The companion build guide is `IMPLEMENTATION_PLAN.md`. Key changes:
88
> 1. **Server-side limit discovery runs at connector construction, not `post_init()`** — upstream `RemoteBackend` never calls `post_init()` on a remote connector ([Section 4.3.6](#436-server-side-record-size-discovery), [Section 4.4.0](#440-construction-time-limit-discovery-_ensure_limits-and-the-post_init-override)).
99
> 2. **Batch API corrected** to `batch_write(BatchRecords([...]))` and `batch_read(keys, [])`; the removed `exists_many`/`get_many`/`select_many` helpers are not used ([Section 4.4.4](#444-async-def-putself-key-cacheenginekey-memory_obj-memoryobj), [Section 4.4.7](#447-def-support_batched_containsself---bool---true-and-def-batched_containsself-keys-listcacheenginekey---int)).
1010
> 3. **Metadata is one serialized `RemoteMetadata` blob (`md` bin), gated on `save_chunk_meta`**, mirroring `FSConnector` — replacing the rigid `shape0..shape3`/`dtype`/`fmt` bins; reads allocate accordingly ([Section 4.3.1](#431-meta-record), [Section 4.4.3](#443-async-def-getself-key-cacheenginekey---optionalmemoryobj)).
1111
> 4. **The connector is serde-agnostic**; `naive`/`cachegen`/`kivi` serde and MLA/layerwise key rewriting happen in `RemoteBackend` above it ([Section 4.4](#44-method-by-method-implementation-spec)).
1212
> 5. **Aerospike client pinned to `>=14,<19`** because `meta={"ttl": N}` is deprecated from `19.1.0`; per-record cap is server-governed (7.1+ `max-record-size` default 1 MiB), and the ops sweet spot is restated as **1-10 KiB** ([Section 2.2](#22-aerospike-just-enough-for-this-design), [Section 4.1](#41-package-layout)).
1313
> 6. **4 MiB `target_segment_bytes` retained** and now cited to the LMCache paper ([arXiv:2510.09665](https://arxiv.org/abs/2510.09665)); the Aerospike ops sweet spot and the LMCache byte-throughput sweet spot are explicitly distinguished ([Section 4.3.4](#434-adaptive-shard-planner)).
14+
> 7. **Phase 3 follows Redis' native mechanics, not its schema by default**: C++ workers, GIL-free pybind submissions, eventfd completions, and direct buffer copies are adopted immediately, while the Phase 1/2 meta+segment schema remains the first native layout. A raw Redis-like schema is reserved for a benchmark-proven follow-up, either as a separate native mode or as a coordinated migration of Phase 1 and Phase 2.
1415
1516
---
1617

@@ -21,7 +22,7 @@
2122
3. [Approaches considered](#3-approaches-considered)
2223
4. [Phase 1 - Remote Storage Plugin (implementation-ready)](#4-phase-1---remote-storage-plugin-implementation-ready)
2324
5. [Phase 2 - StoragePluginInterface and L2 adapter (architectural)](#5-phase-2---storageplugininterface-and-l2-adapter-architectural)
24-
6. [Phase 3 - Native C++ connector (architectural)](#6-phase-3---native-c-connector-architectural)
25+
6. [Phase 3 - Native C++ connector (implementation-ready direction)](#6-phase-3---native-c-connector-implementation-ready-direction)
2526
7. [Open questions](#7-open-questions)
2627
8. [References](#8-references)
2728

@@ -61,9 +62,9 @@ The doc uses these short labels throughout:
6162

6263
| Label | Surface | Status |
6364
| -------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------- |
64-
| `phase 1: remote connector` | `ConnectorAdapter` + `RemoteConnector` (Python) | Implementation-ready in this doc |
65-
| `phase 2: storage plugin / L2 adapter` | `StoragePluginInterface`, `L2AdapterInterface` (Python `plugin` and `native_plugin`) | Architectural in this doc |
66-
| `phase 3: native C++ connector` | `ConnectorBase` (C++/pybind11 against `libaerospike`) | Architectural in this doc |
65+
| `phase 1: remote connector` | `ConnectorAdapter` + `RemoteConnector` (Python) | Implemented |
66+
| `phase 2: storage plugin / L2 adapter` | `StoragePluginInterface`, `L2AdapterInterface` (Python `plugin`) | Implemented |
67+
| `phase 3: native C++ connector` | `ConnectorBase`-style C++/pybind11 against `libaerospike` via LMCache `native_plugin` | Implementation in progress |
6768

6869

6970
### 1.5 Success criteria for Phase 1
@@ -111,7 +112,7 @@ This is the road map for the rest of the doc:
111112
| `ConnectorAdapter` + `RemoteConnector` (Python, single-process worker) | `aerospike` Python client wrapped behind `loop.run_in_executor`; adaptive sharded data model | 1 |
112113
| `StoragePluginInterface` (Python, full backend, non-multiprocess) | Same data model; takes ownership of `LocalCPUBackend` interactions for richer admission control | 2 |
113114
| `L2AdapterInterface` (Python `plugin` and `native_plugin`, multiprocess) | Python L2 wraps Phase 1/2; `native_plugin` exposes a C++ adapter with `eventfd` completions | 2 / 3 |
114-
| C++ `ConnectorBase` (highest throughput, RESP-style) | pybind11 binding over `libaerospike` with `as_event_loop` and zero-copy buffers | 3 |
115+
| Native C++ connector (highest throughput, RESP-style mechanics) | pybind11 binding over `libaerospike`, LMCache native connector protocol, Phase 1/2 schema first | 3 |
115116

116117

117118
---
@@ -791,22 +792,34 @@ Until any of these triggers, Phase 1 is the recommended path and Phase 2 stays a
791792

792793
---
793794

794-
## 6. Phase 3 - Native C++ connector (architectural)
795+
## 6. Phase 3 - Native C++ connector (implementation-ready direction)
795796

796-
Phase 3 replaces the Python connector hot path with a C++ implementation modeled after LMCache's native RESP connector ([`resp_client.py`](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/native_clients/resp_client.py) and the `ConnectorBase` base class it pairs with).
797+
Phase 3 replaces the Python L2 hot path with a C++ implementation modeled after LMCache's native RESP connector ([`resp_client.py`](https://github.com/LMCache/LMCache/blob/dev/lmcache/v1/storage_backend/native_clients/resp_client.py), `NativeConnectorL2Adapter`, and the `ConnectorBase` protocol it pairs with). Redis' winning techniques are the native mechanics: C++ worker tiling, GIL-free pybind submissions, one eventfd-backed completion stream, and direct copies into LMCache-provided buffers. Phase 3 adopts those techniques first while preserving the Phase 1/2 Aerospike schema.
797798

798799
### 6.1 Why we want it
799800

800801
The Python connector ceiling is set by GIL contention on the executor pool, copy overhead through `memoryview`, and the synchronous Aerospike Python client's per-call C extension setup. For sustained multi-GB/s per worker (Llama 70B class at large TP, or multiple concurrent inference requests), this overhead becomes the bottleneck. A native connector closes that gap.
801802

802803
### 6.2 Design sketch
803804

804-
- **Language and bindings.** C++17 implementation; pybind11 binding exposed as `lmcache_aerospike._native` and registered through the LMCache `native_plugin` slot.
805-
- **Client.** Official Aerospike C client (`libaerospike`) using `as_event_loop` for asynchronous operations.
806-
- **Threading.** A dedicated event-loop thread (or thread pool tuned to NIC queues); the Python side never holds the GIL during fetch.
807-
- **Buffers.** Zero-copy: writes consume LMCache-supplied `void*` buffers directly; reads write into LMCache-supplied buffers without an intermediate `bytes` copy.
808-
- **Completion model.** `eventfd`-based; matches the existing LMCache `native_plugin` completion contract.
809-
- **Data model.** Identical to Phase 1 (meta + segments). The native connector reads records written by Phase 1/2 and vice versa.
805+
- **Language and bindings.** C++17 implementation; pybind11 binding exposed as `lmcache_aerospike._native` and loaded through LMCache's `native_plugin` L2 adapter.
806+
- **LMCache native contract.** Expose `event_fd`, `submit_batch_get`, `submit_batch_set`, `submit_batch_exists`, `submit_batch_delete`, `drain_completions`, and `close`, matching `LMCACHE_BIND_CONNECTOR_METHODS` semantics so `NativeConnectorL2Adapter` handles demux, locking, and L2 task accounting.
807+
- **Client.** Official Aerospike C client (`libaerospike`) with one shared cluster client per native connector instance; workers issue key operations against that client with read/write policies matching Phase 1/2 defaults.
808+
- **Threading.** Use the same worker tiling model as LMCache Redis' native connector: each submitted batch is split across C++ worker threads, and one completion is emitted when all tiles finish. The Python side never holds the GIL after pybind has extracted key strings and memoryview pointers.
809+
- **Buffers.** Writes wrap LMCache-supplied buffers with Aerospike C client bytes values where the API allows; reads copy Aerospike bytes directly into LMCache's preallocated `MemoryObj` buffers without a Python `bytes` hop.
810+
- **Completion model.** Eventfd-based, with per-key result bits for lookup/load/delete and one completion per submitted batch.
811+
- **Data model.** Default native layout is the Phase 1/2 meta+segment schema: inline payload bin `b` for single-record objects, segment records for larger objects, and meta-last publish semantics with `state`, `nseg`, `seg_b`, and `tot_b`. Because L2 loads are preallocated, native code does not need to add new LMCache shape/dtype metadata; it only preserves the existing bins required for compatibility and sharding correctness.
812+
813+
### 6.2.1 Schema evolution policy
814+
815+
Phase 3 does **not** begin by switching to a Redis-like raw one-record schema. That schema can reduce bins and branching, but it would break compatibility with Phase 1/2 records unless every Aerospike path migrates together.
816+
817+
The allowed future paths are:
818+
819+
1. **Separate raw native mode:** keep Phase 1/2 compatible schema as the default, and add an opt-in raw native schema if benchmarks prove schema overhead is a top bottleneck.
820+
2. **Coordinated schema migration:** change Phase 1, Phase 2, and Phase 3 to the faster schema together, with explicit migration or dual-read support.
821+
822+
Do not make a schema-breaking change on guesswork. The benchmark loop must first show that the compatible schema, rather than Python overhead, Aerospike policy choices, worker count, network/device bandwidth, or batch shape, is one of the top bottlenecks.
810823

811824
### 6.3 Build and distribution
812825

@@ -821,6 +834,7 @@ The Python connector ceiling is set by GIL contention on the executor pool, copy
821834
- **Build matrix cost.** manylinux wheels, ABI compatibility across `libaerospike` releases, debug story (gdb on the native side, py-spy on the Python side, correlating them).
822835
- **Upstream tracking.** LMCache's `ConnectorBase` is the youngest surface; tracking changes will be ongoing work.
823836
- **Operational surface.** Customers debugging will need both Python and C++ familiarity.
837+
- **Schema pressure.** Preserving Phase 1/2 schema may leave some performance on the table versus Redis' raw key/value storage. Treat this as a measured optimization decision, not a Phase 3 prerequisite.
824838

825839
### 6.5 Decision criteria for Phase 2 -> Phase 3
826840

IMPLEMENTATION_PLAN.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,6 +1286,73 @@ each is updated).
12861286

12871287
---
12881288

1289+
## Phase 3 addendum — native Aerospike L2 connector
1290+
1291+
Phase 3 is implemented after Phase 1 and Phase 2, using LMCache's native
1292+
connector protocol rather than a Python executor hot path. This addendum is the
1293+
source of truth for the native implementation direction.
1294+
1295+
### P3.1 Redis comparison outcome
1296+
1297+
Adopt these native RESP techniques:
1298+
1299+
- C++ worker tiling for batched set/get/exists/delete.
1300+
- pybind methods that release the GIL after extracting key strings and
1301+
memoryview pointers.
1302+
- A single pollable completion fd and one completion per submitted batch.
1303+
- Per-key result bits for lookup/load/delete so LMCache can represent partial
1304+
batch success.
1305+
- Direct copies between Aerospike C client byte values and LMCache-provided
1306+
buffers; no Python `bytes` hop on the hot path.
1307+
1308+
Do **not** adopt Redis' raw schema as the initial Aerospike native schema. Phase
1309+
3 starts compatibility-first: native records use the existing Phase 1/2
1310+
meta+segment layout, including the inline single-record fast path and sharded
1311+
fallback for payloads above the discovered Aerospike record cap.
1312+
1313+
### P3.2 Files to add or modify
1314+
1315+
- Add `csrc/aerospike/connector.h`, `csrc/aerospike/connector.cpp`, and
1316+
`csrc/aerospike/pybind.cpp`.
1317+
- Add `src/lmcache_aerospike/native_connector.py` as the Python factory class
1318+
used by LMCache `native_plugin`.
1319+
- Update `pyproject.toml` and add `setup.py` if setuptools extension wiring is
1320+
needed for `pybind11` and `libaerospike`.
1321+
- Add native L2 adapter JSONs under `benchmarks/l2/adapters/`.
1322+
- Update `benchmarks/l2/run.sh`, `benchmarks/l2/compare.sh`, and
1323+
`benchmarks/l2/README.md` so `aerospike-native` can be benchmarked against
1324+
Redis `resp`.
1325+
- Add unit tests and gated `RUN_NATIVE=1` integration tests.
1326+
1327+
### P3.3 Schema evolution rule
1328+
1329+
The compatible schema remains the default until benchmarks prove it is a top
1330+
bottleneck. If a raw native schema becomes worthwhile, choose one of two paths:
1331+
1332+
1. Add an explicit raw native mode that is documented as incompatible with
1333+
Phase 1/2 records.
1334+
2. Align Phase 1, Phase 2, and Phase 3 to the faster schema together, with
1335+
migration or dual-read coverage.
1336+
1337+
Do not silently make Phase 1/2 configs write one schema while native L2 writes
1338+
another schema under the same backend name.
1339+
1340+
### P3.4 Verification gates
1341+
1342+
- `pytest tests/unit -q`
1343+
- `RUN_NATIVE=1 pytest tests/integration -q` when `libaerospike` and Aerospike CE
1344+
are available.
1345+
- `./benchmarks/l2/run.sh --backend aerospike-native`
1346+
- `./benchmarks/l2/run.sh --backend resp`
1347+
- `./benchmarks/l2/compare.sh --native` or the equivalent sequential comparison
1348+
command added by Phase 3.
1349+
1350+
If Aerospike native is still materially slower than Redis, inspect benchmark
1351+
output before changing schema. First optimize worker counts, Aerospike C client
1352+
policies, batch shape, batch API usage, and per-op allocation overhead.
1353+
1354+
---
1355+
12891356
## Final acceptance checklist (Phase 1 "done")
12901357

12911358
Mirrors DESIGN §1.5, with corrections.

0 commit comments

Comments
 (0)