Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

Commit 4a46f4e

Browse files
committed
add local format design doc
Signed-off-by: aoiasd <zhicheng.yue@zilliz.com>
1 parent 83f85b5 commit 4a46f4e

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
# MEP: Local Format — Pluggable On-Node Data Formats for Sealed Segments
2+
3+
- **Created:** 2026-03-05
4+
- **Author(s):** @zhicheng
5+
- **Status:** Draft
6+
- **Component:** QueryNode | DataNode | Storage
7+
- **Related Issues:** TBD
8+
- **Released:** TBD
9+
10+
## Summary
11+
12+
Introduce a **local_format** field-level property that controls how sealed segment data is stored and accessed on query nodes. The default format is the existing Milvus row-oriented layout (RowChunk); the first alternative is **Vortex**, a compressed columnar format that reduces memory and disk footprint while maintaining query performance through on-demand decompression.
13+
14+
## Motivation
15+
16+
Milvus sealed segments currently store field data in an uncompressed, row-oriented binary layout (RowChunk). This works well for random access but has two drawbacks:
17+
18+
1. **Memory pressure**: All field data must be fully decompressed in memory (or mmap'd at full size). For large scalar fields (VARCHAR, JSON), this can consume significant RAM.
19+
2. **No format flexibility**: The on-node data format is hard-coded. There is no way to choose a format that trades CPU (decompression) for memory/disk savings.
20+
21+
The **local_format** feature addresses this by:
22+
23+
- Allowing users to specify `local_format=vortex` per field at collection creation time.
24+
- Keeping compressed data on disk (via mmap) and decompressing only the accessed portion on demand.
25+
- Providing a unified data access interface (`ChunkDataView`) so upper layers are format-agnostic.
26+
27+
## Public Interfaces
28+
29+
### Collection Creation
30+
31+
A new type parameter `local_format` is added to field schemas:
32+
33+
```python
34+
schema.add_field(
35+
field_name="description",
36+
datatype=DataType.VARCHAR,
37+
max_length=65535,
38+
local_format="vortex" # new parameter
39+
)
40+
```
41+
42+
Valid values: `"row"` (default), `"vortex"`.
43+
44+
Applies to **non-primary-key scalar fields only**. This includes all non-vector, non-PK types: INT8/16/32/64, FLOAT, DOUBLE, BOOL, VARCHAR, JSON, ARRAY, BSON, etc. Vector fields and primary key fields ignore this parameter.
45+
46+
### Storage Configuration
47+
48+
A global storage format config controls the file format written to object storage:
49+
50+
```yaml
51+
common:
52+
storage:
53+
format: vortex # options: parquet, vortex
54+
```
55+
56+
When `format=vortex`, the write path (DataNode flush / compaction) produces Vortex-encoded files for column groups that contain `local_format=vortex` fields. Other column groups continue to use Parquet.
57+
58+
### Relationship between `storage.format` and `local_format`
59+
60+
These two configs are independent and serve different purposes:
61+
62+
- **`storage.format`** controls the **write path** — what file format is used when flushing/compacting new segments to object storage (S3). Changing this config only affects newly written files; existing files on S3 are not affected.
63+
- **`local_format`** controls the **read path** — how query nodes load and access field data from sealed segments.
64+
65+
`local_format=vortex` requires Storage V3 and the actual file on S3 to be in vortex format. If a segment's files are still in Parquet format (e.g., written before `storage.format` was changed to vortex), the segment **gracefully falls back to Row format** loading — no error is raised. This means `local_format` is a "preferred" format, not a hard requirement. Only when the on-disk file is actually vortex will the vortex loading path be used.
66+
67+
## Design Details
68+
69+
### 1. Architecture Overview
70+
71+
```
72+
┌─────────────────────────────────────────────────────────┐
73+
│ Query / Expression │
74+
│ chunk_view<T>() unified API │
75+
├─────────────────────────────────────────────────────────┤
76+
│ AnyDataView (type erasure) │
77+
├──────────────────────┬──────────────────────────────────┤
78+
│ ContiguousDataView │ VortexDataView │
79+
│ (RowChunk path) │ (VortexChunk path) │
80+
│ data in memory or │ lazy decompression │
81+
│ mmap'd flat buffer │ via Reader/ChunkReader │
82+
├──────────────────────┴──────────────────────────────────┤
83+
│ Chunk Layer │
84+
│ RowChunk (ScalarChunk, StringChunk, ...) │
85+
│ VortexChunk (lightweight, no decompressed data) │
86+
├─────────────────────────────────────────────────────────┤
87+
│ GroupChunkTranslator (Parquet) │
88+
│ VortexGroupChunkTranslator (Vortex) │
89+
├─────────────────────────────────────────────────────────┤
90+
│ Object Storage (S3 / MinIO / Local) │
91+
│ Parquet files | Vortex files │
92+
└─────────────────────────────────────────────────────────┘
93+
```
94+
95+
### 2. Column Group Splitting
96+
97+
Fields with `local_format=vortex` are split into a separate column group from default (row) format fields. This is handled by `LocalFormatPolicy` in the column group splitting pipeline:
98+
99+
```
100+
DefaultPolicies execution order:
101+
1. SystemColumnPolicy — split system fields (RowID, Timestamp) + PK
102+
2. AvgSizePolicy — split large fields by avg size
103+
3. SelectedDataTypePolicy — split vector / text fields (each gets own group)
104+
4. LocalFormatPolicy — split vortex fields into separate group
105+
5. RemanentShortPolicy — merge remaining short fields
106+
```
107+
108+
This ensures vortex fields and non-vortex fields are never mixed in the same column group file.
109+
110+
### 3. Write Path
111+
112+
When `storage.format=vortex`, the packed writer (via Rust FFI to milvus-storage) encodes column groups in Vortex format. The Vortex file is a single file per column group, containing all fields in that group.
113+
114+
The binlog path structure is unchanged:
115+
```
116+
insert_log/{collID}/{partID}/{segID}/{groupID}/{logID}
117+
```
118+
119+
Where `groupID` is the column group ID (assigned by split policies), and `logID` is the allocated log ID.
120+
121+
### 4. Read Path — VortexGroupChunkTranslator
122+
123+
On query node load, the segment loading path checks per-field `local_format`:
124+
125+
- If all fields in a column group have `local_format=vortex` AND the file format is vortex → use `VortexGroupChunkTranslator`
126+
- Otherwise → use standard `GroupChunkTranslator` (Parquet path)
127+
128+
**Key design: BufferFileSystem as data bridge**
129+
130+
The milvus-storage `Reader` API reads data through an `arrow::fs::FileSystem` abstraction. It does not accept raw memory buffers directly. To enable decompression from in-memory (or mmap'd) data, we implement a custom `BufferFileSystem` (a subclass of `arrow::fs::FileSystem`) that serves compressed vortex data from memory buffers as if they were files.
131+
132+
The flow is:
133+
134+
```
135+
S3 download → Arrow Buffer (in-memory)
136+
→ BufferFileSystem::Register("mem://path", buffer)
137+
→ register BufferFileSystem in milvus-storage FilesystemCache (key="mem")
138+
→ Reader resolves "mem://path" via FilesystemCache → BufferFileSystem
139+
→ BufferFileSystem::OpenInputFile() → BufferReader (zero-copy)
140+
→ Reader/ChunkReader decompress from buffer on demand
141+
```
142+
143+
This same pattern extends to mmap: instead of downloading into an Arrow Buffer, the vortex file is written to local disk and mmap'd. The mmap pointer is wrapped as `arrow::Buffer::Wrap(mmap_ptr, size)` and registered in BufferFileSystem identically. The Reader operates on the mmap'd data transparently — the only difference is where the underlying bytes reside (heap vs mmap'd pages).
144+
145+
`BufferFileSystem` is a read-only singleton that implements:
146+
- `Register(path, buffer)` / `Unregister(path)` — manage path-to-buffer mappings
147+
- `OpenInputFile(path)` → `arrow::io::BufferReader` (zero-copy read from buffer)
148+
- `GetFileInfo(path)` → file size from buffer
149+
- `type_name()` → `"mem"` (used as FilesystemCache key)
150+
151+
**VortexGroupChunkTranslator construction:**
152+
153+
1. Download vortex files from object storage into Arrow Buffers (in-memory)
154+
2. Register buffers in `BufferFileSystem` singleton with `mem://` paths
155+
3. Register `BufferFileSystem` in milvus-storage `FilesystemCache` with key `"mem"` (idempotent)
156+
4. Rewrite column group file paths from `s3://...` to `mem://...`
157+
5. Create milvus-storage `Reader` from rewritten column groups — Reader resolves `mem://` paths through `FilesystemCache` → `BufferFileSystem`
158+
6. Extract row group metadata (sizes, row counts) via `ChunkReader`
159+
7. Merge row groups into cache cells (4 row groups per cell, same as Parquet)
160+
161+
**VortexGroupChunkTranslator::get_cells():**
162+
163+
For each requested cell:
164+
1. Create a shared `ChunkReader` for the cell
165+
2. For each field in the column group, create a `VortexChunk` holding:
166+
- Shared `Reader` (for point queries via `take()`)
167+
- Shared `ChunkReader` (for bulk decompression via `get_chunks()`)
168+
- Row group indices for this cell
169+
- Column index in batch, row start offset
170+
171+
### 5. VortexChunk — Lightweight Chunk
172+
173+
`VortexChunk` extends `Chunk` but holds **no decompressed data**. It is a lightweight handle:
174+
175+
```cpp
176+
class VortexChunk : public Chunk {
177+
// These throw NotImplemented — callers must use GetAnyDataView()
178+
const char* ValueAt(int64_t idx) const override;
179+
const char* Data() const override;
180+
181+
// Returns lazy VortexDataView — decompression on demand
182+
AnyDataView GetAnyDataView() const override;
183+
AnyDataView GetAnyDataView(int64_t offset, int64_t length) const override;
184+
185+
private:
186+
std::shared_ptr<Reader> reader_; // for point queries
187+
std::shared_ptr<ChunkReader> chunk_reader_; // for bulk decompression
188+
std::vector<int64_t> chunk_indices_; // row group indices
189+
int column_in_batch_; // column position
190+
int64_t row_start_; // global row offset
191+
};
192+
```
193+
194+
### 6. VortexDataView — Lazy Decompression
195+
196+
Each `GetAnyDataView()` call returns a type-specific `VortexDataView` that decompresses on demand:
197+
198+
| DataView Class | Data Types | operator[](idx) | Data() (bulk) |
199+
|---|---|---|---|
200+
| `VortexNumericDataView<T>` | int8–64, float, double | `Reader::take({row_start+idx})` | `ChunkReader::get_chunks()` → memcpy |
201+
| `VortexBoolDataView` | bool | `take()` → BooleanArray | `get_chunks()` → bit unpack |
202+
| `VortexStringDataView` | string_view (VARCHAR, BSON) | `take()` → BinaryArray | `get_chunks()` → string_view array |
203+
| `VortexJsonDataView` | Json | `take()` → BinaryArray → Json | `get_chunks()` → Json array |
204+
| `VortexArrayDataView` | ArrayView (ARRAY) | `take()` → ListArray → ArrayView | `get_chunks()` → ArrayView array |
205+
206+
All types share `VortexDataViewCore` which encapsulates:
207+
- Point query: `Reader::take()` for single-row access
208+
- Bulk query: `ChunkReader::get_chunks()` for full decompression
209+
- `data_offset` field for range sub-views (`GetAnyDataView(offset, length)`)
210+
211+
**Decompressed data lifetime**: Data is held in the `VortexDataView` instance (mutable lazy fields). When the `AnyDataView` is destroyed, decompressed data is freed. This keeps memory usage bounded to the currently active query's working set.
212+
213+
**Thread safety**:
214+
- `Reader` and `ChunkReader` (from milvus-storage) are **thread-safe** (Rust `Send + Sync`). Multiple VortexChunks safely share the same instances.
215+
- `VortexDataView` instances are **not thread-safe**. Internal mutable lazy caches have no locking. This matches the usage pattern: each query thread holds its own `PinWrapper<DataView>` instance, so concurrent access to a single DataView does not occur.
216+
217+
### 7. Unified Data Access — ChunkDataView
218+
219+
The `ChunkDataView<T>` interface provides format-agnostic data access:
220+
221+
```cpp
222+
template <typename T>
223+
class ChunkDataView : public BaseDataView {
224+
virtual const T& operator[](int64_t idx) const = 0; // point access
225+
virtual const T* Data() const = 0; // bulk access
226+
int64_t RowCount() const;
227+
const bool* ValidData() const;
228+
};
229+
```
230+
231+
Two concrete implementations:
232+
- `ContiguousDataView<T>`: For RowChunk — wraps raw pointer to in-memory/mmap'd data
233+
- `VortexDataView` variants: For VortexChunk — lazy decompression
234+
235+
Upper layers (expression evaluation, search, retrieve) call `chunk_view<T>()` which returns a `PinWrapper<shared_ptr<ChunkDataView<T>>>`. They never know which format underlies the data.
236+
237+
### 8. Mmap Support (WIP)
238+
239+
> This section is work-in-progress. Details will be finalized in a follow-up iteration.
240+
241+
**Current state**: Vortex files are downloaded into Arrow Buffers in RAM.
242+
243+
**Planned design**: Mmap the compressed vortex file to local disk.
244+
245+
```
246+
S3 download → write to local file → mmap(PROT_READ, MAP_SHARED)
247+
→ arrow::Buffer::Wrap(mmap_ptr, size) // zero-copy
248+
→ register in BufferFileSystem
249+
→ Reader works normally over mmap'd data
250+
```
251+
252+
Key differences from Parquet mmap:
253+
254+
| | Parquet mmap | Vortex mmap |
255+
|---|---|---|
256+
| What's on disk | Decompressed flat data | Compressed vortex file |
257+
| Disk usage | = raw data size | << raw data size |
258+
| Access pattern | Direct pointer read | Decompress on access |
259+
| CPU overhead | None | Decompression per access |
260+
| Page-in granularity | OS 4KB pages | OS 4KB pages |
261+
262+
The OS transparently manages page-in/page-out at 4KB granularity. Compressed data that hasn't been accessed recently is evicted from physical RAM by the OS without application involvement. Decompressed data is temporary (lives only in VortexDataView lifetime) and freed after use.
263+
264+
Memory footprint ≈ size of one decompressed row group batch, regardless of total segment size.
265+
266+
## Compatibility, Deprecation, and Migration Plan
267+
268+
- **Backward compatible**: Fields without `local_format` default to `"row"` (existing behavior).
269+
- **Mixed format**: A collection can have some fields with `local_format=vortex` and others with default. They are stored in separate column groups.
270+
- **Upgrade path**: Existing segments remain in Parquet/row format. New segments written with `storage.format=vortex` use the vortex format. Compaction can convert old segments to new format.
271+
- **Downgrade**: If `local_format=vortex` fields are loaded by a Milvus version that doesn't support vortex, loading will fail with a clear error message.
272+
273+
## Test Plan
274+
275+
- **Unit tests**: ChunkDataView, VortexDataView, VortexChunk GetAnyDataView for all scalar types
276+
- **Integration tests**: Insert → flush → load → query/search/retrieve with vortex VARCHAR, INT, JSON fields
277+
- **Mmap tests**: Verify vortex mmap path produces correct results with memory-constrained scenarios
278+
- **Compatibility tests**: Mixed format collections (some fields vortex, some row)
279+
- **Performance benchmarks**: Compare query latency and memory usage between row and vortex formats
280+
281+
## References
282+
283+
- [Apache Vortex](https://github.com/spiraldb/vortex) — Compressed columnar format
284+
- [milvus-storage](https://github.com/milvus-io/milvus-storage) — Milvus storage layer with Vortex support
285+
- Milvus ChunkDataView design: `internal/core/src/common/ChunkDataView.h`
286+
- Milvus CacheSlot architecture: `cachinglayer/CacheSlot.h`

0 commit comments

Comments
 (0)