mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 10:15:43 +00:00
feat: add result cache function for expr (#48873)
#50225 Co-authored-by: luzhang <luzhang@zilliz.com>
This commit is contained in:
+9
-1
@@ -597,7 +597,15 @@ queryNode:
|
||||
delegatorPostLoadConcurrencyFactor: 1 # delegator post-load concurrency factor after worker LoadSegments returns. Concurrency is hardware.GetCPUNum * factor
|
||||
exprCache:
|
||||
enabled: false # enable expression result cache
|
||||
capacityBytes: 268435456 # max capacity in bytes for expression result cache
|
||||
mode: disk # cache mode: 'disk' (sealed segments only, pread/pwrite + fixed slots) or 'memory' (sealed and growing segments, malloc + Clock + compression)
|
||||
minEvalDurationUs: 1000 # global latency filter: skip expressions that eval faster than this (0=disabled)
|
||||
admissionThreshold: 2 # frequency admission for memory and disk mode: cache after N+ occurrences (1=no gating)
|
||||
memory:
|
||||
maxBytes: 268435456 # max memory for expression cache in memory mode (default 256MB)
|
||||
compressionEnabled: true # enable Roaring/Raw adaptive compression in memory mode
|
||||
disk:
|
||||
maxBytes: 10737418240 # max total disk usage for expression cache in disk mode (default 10GB)
|
||||
maxFileSizeBytes: 268435456 # max file size per sealed segment in disk mode (default 256MB)
|
||||
dataSync:
|
||||
flowGraph:
|
||||
maxQueueLength: 16 # The maximum size of task queue cache in flow graph in query node.
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# MEP: Expression Result Cache
|
||||
|
||||
- **Created:** 2026-06-02
|
||||
- **Author(s):** @luzhang
|
||||
- **Status:** Under Review
|
||||
- **Component:** QueryNode / Segcore
|
||||
- **Related Issues:** N/A
|
||||
- **Released:** N/A
|
||||
|
||||
## Summary
|
||||
|
||||
Expression Result Cache is a QueryNode-local cache for scalar filter expression bitmaps. It stores the full active-segment result bitmap produced by an expression and reuses it when the same expression is evaluated again on the same segment snapshot.
|
||||
|
||||
The cache is implemented in segcore through `ExprResCacheManager`, with two storage modes:
|
||||
|
||||
- **Memory mode:** heap memory, adaptive bitmap compression, frequency and latency admission, and Clock eviction.
|
||||
- **Disk mode:** one fixed-slot cache file per sealed segment, direct `pread`/`pwrite`, frequency and latency admission, per-segment Clock eviction, and global segment-file eviction.
|
||||
|
||||
The feature is controlled by refreshable `queryNode.exprCache` parameters. It is disabled by default and does not change query semantics when disabled.
|
||||
|
||||
## Motivation
|
||||
|
||||
QueryNode can repeatedly evaluate the same scalar filter expression on the same segment. This happens in repeated search/query workloads, two-stage retrieval, text match filters, JSON path filters, and scalar index/statistics based predicates.
|
||||
|
||||
Some expressions are expensive because they need to:
|
||||
|
||||
- scan field data,
|
||||
- evaluate JSON paths,
|
||||
- access scalar or text indexes,
|
||||
- build full result and validity bitmaps.
|
||||
|
||||
Recomputing the same bitmap wastes CPU and increases tail latency. Caching the full-segment bitmap avoids this work when the effective expression and segment snapshot are unchanged.
|
||||
|
||||
## Goals
|
||||
|
||||
- Cache reusable expression result bitmaps by segment and expression signature.
|
||||
- Support both memory-backed and disk-backed cache modes.
|
||||
- Preserve correctness when a segment's active row count changes.
|
||||
- Make cache parameters refreshable without restarting QueryNode.
|
||||
- Track memory and disk usage through existing cachinglayer metrics.
|
||||
- Provide shared helpers for expression implementations to use the get-compute-put pattern.
|
||||
- Keep the default disabled path close to the original execution path.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Sharing cache entries across QueryNodes.
|
||||
- Persisting disk cache files across process restarts or config rebuilds.
|
||||
- Supporting growing segments in disk mode.
|
||||
- Caching final search results or vector-search intermediate results.
|
||||
- Replacing specialized scalar, text, JSON, or vector indexes.
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
### QueryNode Configuration
|
||||
|
||||
```yaml
|
||||
queryNode:
|
||||
exprCache:
|
||||
enabled: false
|
||||
mode: disk
|
||||
minEvalDurationUs: 1000
|
||||
admissionThreshold: 2
|
||||
memory:
|
||||
maxBytes: 268435456
|
||||
compressionEnabled: true
|
||||
disk:
|
||||
maxBytes: 10737418240
|
||||
maxFileSizeBytes: 268435456
|
||||
```
|
||||
|
||||
| Key | Default | Hot-reload | Description |
|
||||
|-----|---------|------------|-------------|
|
||||
| `queryNode.exprCache.enabled` | `false` | yes | Enable expression result cache. |
|
||||
| `queryNode.exprCache.mode` | `disk` | yes | Cache backend: `disk` or `memory`. |
|
||||
| `queryNode.exprCache.minEvalDurationUs` | `1000` | yes | Skip caching expressions that evaluate faster than this threshold. `0` disables latency admission. |
|
||||
| `queryNode.exprCache.admissionThreshold` | `2` | yes | Frequency admission threshold shared by memory and disk modes. `1` disables frequency admission. |
|
||||
| `queryNode.exprCache.memory.maxBytes` | `268435456` | yes | Maximum memory budget for memory mode. |
|
||||
| `queryNode.exprCache.memory.compressionEnabled` | `true` | yes | Enable adaptive bitmap compression in memory mode. |
|
||||
| `queryNode.exprCache.disk.maxBytes` | `10737418240` | yes | Maximum logical used-slot disk budget for disk mode. |
|
||||
| `queryNode.exprCache.disk.maxFileSizeBytes` | `268435456` | yes | Maximum cache file size per sealed segment in disk mode. |
|
||||
|
||||
### C API
|
||||
|
||||
Go-side paramtable refresh propagates config into C++ through:
|
||||
|
||||
```cpp
|
||||
void SetExprResCacheEnable(bool val);
|
||||
|
||||
void SetExprResCacheConfig(const char* mode,
|
||||
const char* disk_base_path,
|
||||
int64_t mem_max_bytes,
|
||||
bool compression_enabled,
|
||||
int32_t admission_threshold,
|
||||
int64_t mem_min_eval_duration_us,
|
||||
int64_t disk_max_bytes,
|
||||
int64_t disk_max_file_size,
|
||||
int64_t disk_min_eval_duration_us);
|
||||
```
|
||||
|
||||
### Internal Cache API
|
||||
|
||||
`ExprResCacheManager` exposes a mode-independent API:
|
||||
|
||||
```cpp
|
||||
struct Key {
|
||||
int64_t segment_id;
|
||||
std::string signature;
|
||||
};
|
||||
|
||||
struct Value {
|
||||
std::shared_ptr<TargetBitmap> result;
|
||||
std::shared_ptr<TargetBitmap> valid_result;
|
||||
int64_t active_count;
|
||||
size_t bytes;
|
||||
int64_t eval_duration_us;
|
||||
};
|
||||
|
||||
bool Get(const Key& key, Value& out_value);
|
||||
void Put(const Key& key, const Value& value);
|
||||
void Clear();
|
||||
size_t EraseSegment(int64_t segment_id);
|
||||
bool SetConfig(const CacheConfig& config);
|
||||
```
|
||||
|
||||
`Get` requires the caller to set `out_value.active_count` before calling. The cache uses it to reject stale entries.
|
||||
|
||||
## Design Details
|
||||
|
||||
### 1. Architecture
|
||||
|
||||
```text
|
||||
Expression execution
|
||||
|
|
||||
| ExprCacheHelper::GetOrCompute
|
||||
| SegmentExpr::TryCacheGet / CachePut
|
||||
| FilterBitsNode whole-filter cache
|
||||
v
|
||||
ExprResCacheManager
|
||||
|
|
||||
+-- Memory mode -> EntryPool
|
||||
| +-- adaptive compression
|
||||
| +-- latency and frequency admission
|
||||
| +-- Clock eviction
|
||||
|
|
||||
+-- Disk mode -> DiskSlotFile per sealed segment
|
||||
+-- fixed-size raw bitmap slots
|
||||
+-- pread / pwrite
|
||||
+-- per-file Clock eviction
|
||||
+-- global segment-file Clock eviction
|
||||
```
|
||||
|
||||
`ExprResCacheManager` owns mode selection, frequency admission, dynamic config rebuild, segment erasure, and usage metrics. Backend implementations focus on storage-specific behavior.
|
||||
|
||||
### 2. Cache Key and Value
|
||||
|
||||
The external cache key is `(segment_id, expression_signature)`.
|
||||
|
||||
The expression signature is normally `expr->ToString()` or `this->ToString()`. It must include every parameter that can affect the result:
|
||||
|
||||
- field id,
|
||||
- operator type,
|
||||
- literal values,
|
||||
- JSON path,
|
||||
- text query,
|
||||
- match options,
|
||||
- query-time context when relevant.
|
||||
|
||||
The cached value stores:
|
||||
|
||||
- result bitmap,
|
||||
- validity bitmap,
|
||||
- active row count,
|
||||
- miss-path evaluation duration for admission decisions.
|
||||
|
||||
Correctness depends on:
|
||||
|
||||
- the same segment id, same signature, and same active count producing the same bitmaps;
|
||||
- callers passing the current segment row count as `active_count`;
|
||||
- cache hits verifying `active_count`.
|
||||
|
||||
If `active_count` mismatches, the entry is treated as stale and the request falls back to normal expression evaluation.
|
||||
|
||||
### 3. Memory Backend
|
||||
|
||||
Memory mode uses `EntryPool`.
|
||||
|
||||
It supports both sealed and growing segments. Internally, `EntryPool` keys entries by:
|
||||
|
||||
- segment id,
|
||||
- signature hash,
|
||||
- full signature,
|
||||
- active count.
|
||||
|
||||
This isolates growing-segment snapshots with different active row counts.
|
||||
|
||||
Memory mode stores payloads in heap memory. `CacheCompressor` chooses the encoding:
|
||||
|
||||
| Bitmap pattern | Encoding |
|
||||
|----------------|----------|
|
||||
| Sparse result bitmap | Roaring |
|
||||
| Very dense result bitmap | Inverted Roaring |
|
||||
| Medium-density bitmap | Raw bytes |
|
||||
| Compression disabled | Raw bytes |
|
||||
|
||||
When the validity bitmap is all ones, memory mode records that state as metadata and avoids storing a separate validity payload.
|
||||
|
||||
Eviction uses Clock. `Get` takes a shared lock and updates an atomic usage counter; `Put` takes an exclusive lock and may evict entries until the memory budget is satisfied.
|
||||
|
||||
### 4. Disk Backend
|
||||
|
||||
Disk mode uses `DiskSlotFile`. It is sealed-segment only because slot size is derived from the segment row count at file creation time.
|
||||
|
||||
File layout:
|
||||
|
||||
```text
|
||||
[FileHeader 64B][slot_0][slot_1]...[slot_N-1]
|
||||
```
|
||||
|
||||
Slot layout:
|
||||
|
||||
```text
|
||||
[SlotHeader 17B][raw result bitmap][raw valid bitmap]
|
||||
```
|
||||
|
||||
Each segment owns one cache file:
|
||||
|
||||
```text
|
||||
<localStorage.path>/cache/<nodeID>/expr_cache/seg_<segment_id>.cache
|
||||
```
|
||||
|
||||
Disk files are temporary process-local cache files. Signature-to-slot metadata is kept in memory, so old `.cache` files are removed when disk config is applied or rebuilt.
|
||||
|
||||
If the same segment later appears with a different row count, the fixed slot file no longer matches the bitmap shape. The manager removes the file and marks the segment ineligible for disk caching until the segment or config is reset.
|
||||
|
||||
Disk mode has two size limits:
|
||||
|
||||
- `queryNode.exprCache.disk.maxFileSizeBytes` limits one sealed segment file and determines how many fixed slots the segment file can hold.
|
||||
- `queryNode.exprCache.disk.maxBytes` limits total logical used-slot bytes across disk cache files. The budget counts `FileHeader + used_slots * slot_size`, not the full preallocated file capacity.
|
||||
|
||||
Within one `DiskSlotFile`, slot eviction uses Clock. Across segment files, `Get` hits and `Put` writes touch a segment-level Clock usage counter. After a disk `Put`, the manager checks total used-slot bytes. If usage exceeds `disk.maxBytes`, it scans segment files with a second-chance Clock policy and evicts whole segment files whose usage counter has decayed to zero, skipping the segment that was just written.
|
||||
|
||||
### 5. Admission Control
|
||||
|
||||
Two admission policies are applied before writing a new entry:
|
||||
|
||||
- **Latency admission:** skip expressions whose miss-path evaluation duration is lower than `queryNode.exprCache.minEvalDurationUs`.
|
||||
- **Frequency admission:** cache only after the expression has been observed at least `queryNode.exprCache.admissionThreshold` times.
|
||||
|
||||
Frequency admission is mode-independent and is owned by `ExprResCacheManager`, not by `EntryPool`, so memory and disk use the same policy.
|
||||
|
||||
Existing same-signature entries can be updated without re-running frequency admission. This allows refreshed snapshots for the same expression to update the cache after the expression has already been admitted.
|
||||
|
||||
### 6. Dynamic Refresh Semantics
|
||||
|
||||
All `queryNode.exprCache` parameters are refreshable.
|
||||
|
||||
Refresh behavior is conservative:
|
||||
|
||||
- `enabled=false` disables future cache get/put operations.
|
||||
- `enabled=true` first applies the current config, then enables cache access.
|
||||
- Changing any cache config while enabled calls `SetConfig`.
|
||||
- `SetConfig` rebuilds the backend and clears existing cache entries.
|
||||
- In disk mode, `SetConfig` removes old `.cache` files in the target cache directory.
|
||||
- Invalid config or disk directory creation failure disables the cache and clears backend state.
|
||||
|
||||
When thresholds such as `admissionThreshold` or `minEvalDurationUs` change, existing entries are not reinterpreted. They are dropped, and future evaluations repopulate the cache under the new policy.
|
||||
|
||||
### 7. Expression Integration
|
||||
|
||||
The common integration path is `ExprCacheHelper::GetOrCompute`:
|
||||
|
||||
1. Check whether cache is enabled and the segment is eligible.
|
||||
2. In disk mode, reject growing segments.
|
||||
3. Build the `(segment_id, signature)` key.
|
||||
4. Attempt cache `Get`.
|
||||
5. On miss, compute the full-segment bitmap.
|
||||
6. Measure evaluation duration when cache admission may need it.
|
||||
7. Attempt cache `Put`.
|
||||
|
||||
For Volcano-style batched expressions, `BatchedCachedMixin` loads or computes the full-segment bitmap once, then slices it on later `Eval()` calls.
|
||||
|
||||
Current integration points include:
|
||||
|
||||
- `BinaryRangeExpr`
|
||||
- `TermExpr`
|
||||
- `JsonContainsExpr`
|
||||
- `ExistsExpr`
|
||||
- `UnaryExpr` TextMatch / PhraseMatch
|
||||
- index/statistics paths through `SegmentExpr::TryCacheGet` and `SegmentExpr::CachePut`
|
||||
- whole-filter reuse through `FilterBitsNode`
|
||||
|
||||
Integration rules:
|
||||
|
||||
- Cache the full active-segment bitmap, not a single batch.
|
||||
- Include all result-affecting parameters in the signature.
|
||||
- Cache both result and validity bitmaps for nullable expressions.
|
||||
- Do not mutate shared bitmaps returned from cache.
|
||||
|
||||
### 8. Segment Lifecycle
|
||||
|
||||
Segment release can call:
|
||||
|
||||
```cpp
|
||||
EraseSegmentCache(segment_id)
|
||||
```
|
||||
|
||||
In memory mode, this removes all entries for the segment from `EntryPool`.
|
||||
|
||||
In disk mode, this closes and deletes the segment cache file, clears the segment's ineligible marker, and removes the segment from global disk Clock metadata.
|
||||
|
||||
### 9. Metrics and Resource Accounting
|
||||
|
||||
The cache reports usage through existing cachinglayer gauges:
|
||||
|
||||
| Metric | Meaning |
|
||||
|--------|---------|
|
||||
| `cache_loaded_bytes{cell_data_type="OTHER", storage_type="MEMORY"}` | Current expression cache memory usage. |
|
||||
| `cache_loaded_bytes{cell_data_type="OTHER", storage_type="DISK"}` | Current expression cache disk used-slot logical bytes. |
|
||||
|
||||
`ExprResCacheManager::SyncUsageMetrics` tracks the last reported memory/disk bytes and updates gauges by delta. This avoids double counting.
|
||||
|
||||
Usage is synchronized after:
|
||||
|
||||
- memory put,
|
||||
- disk put,
|
||||
- segment erase,
|
||||
- clear,
|
||||
- config rebuild,
|
||||
- disk config failure cleanup.
|
||||
|
||||
### 10. Concurrency
|
||||
|
||||
`ExprResCacheManager` uses:
|
||||
|
||||
- `state_mutex_` for config and active backend state;
|
||||
- `disk_files_mutex_` for the disk segment-file map;
|
||||
- `disk_clock_mutex_` for segment-level disk Clock metadata;
|
||||
- atomic `enabled_`;
|
||||
- atomic reported metric bytes.
|
||||
|
||||
`Get` and `Put` re-check `IsEnabled()` after acquiring `state_mutex_`, preventing requests that entered before a refresh from using a backend after the cache has been disabled.
|
||||
|
||||
Backend concurrency:
|
||||
|
||||
- `EntryPool` uses shared/exclusive locking around its entry index.
|
||||
- `DiskSlotFile` uses a shared mutex around slot metadata and file operations.
|
||||
- Disk used-byte accounting reads `DiskSlotFile` metadata under its shared mutex.
|
||||
|
||||
### 11. Disabled Path Performance
|
||||
|
||||
The feature is disabled by default.
|
||||
|
||||
When disabled, cache manager operations return before building cache keys or taking backend locks. Most expression integrations therefore add only an atomic enabled check and a branch.
|
||||
|
||||
Integration code should avoid doing cache-only work when disabled. In particular:
|
||||
|
||||
- do not build expression cache signatures before checking the query/context cache flag;
|
||||
- do not clone bitmaps only for cache writes unless the cache is eligible;
|
||||
- only measure miss-path evaluation time when the result may be admitted into the cache.
|
||||
|
||||
### 12. Failure Handling
|
||||
|
||||
The cache is best-effort and must not fail user queries.
|
||||
|
||||
Failure behavior:
|
||||
|
||||
- invalid mode disables cache;
|
||||
- non-positive memory or disk size disables cache;
|
||||
- disk directory creation failure disables cache and clears backend state;
|
||||
- disk file removal failures are logged as warnings;
|
||||
- cache miss, stale entry, or ineligible segment falls back to normal expression evaluation.
|
||||
|
||||
### 13. Relationship with Two-Stage FilterBits Cache
|
||||
|
||||
Expression result cache and `FilterBitsNode` cache are different cache layers.
|
||||
|
||||
- Expression-level cache key: a sub-expression signature, such as a TextMatch expression.
|
||||
- `FilterBitsNode` cache key: the whole filter expression plus dynamic filter context such as entity TTL physical time.
|
||||
|
||||
Both can reuse `ExprResCacheManager`.
|
||||
|
||||
For two-stage search, `QueryContext` can allow whole-filter cache reads and writes while disabling sub-expression cache writes. This prevents caching both a full-filter bitmap and duplicate child-expression bitmaps for the same request path.
|
||||
|
||||
## Correctness Guarantees
|
||||
|
||||
- **No stale row-count reuse.** Every cache hit verifies `active_count`.
|
||||
- **Nullable correctness.** Result and validity bitmaps are cached together.
|
||||
- **Disk mode sealed-only.** Growing segments are rejected before disk cache usage.
|
||||
- **Best-effort fallback.** Misses and cache failures fall back to regular expression evaluation.
|
||||
- **Config changes do not reinterpret entries.** Config refresh rebuilds the backend and drops existing entries.
|
||||
|
||||
## Compatibility and Migration
|
||||
|
||||
The feature is controlled by `queryNode.exprCache.enabled`, defaulting to `false`.
|
||||
|
||||
All config values are refreshable. Operators can enable, disable, or tune the cache without restarting QueryNode.
|
||||
|
||||
The feature does not change client-facing API or query semantics.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `ExprResCacheManager` basic put/get.
|
||||
- Enable/disable behavior.
|
||||
- Segment erase in memory and disk mode.
|
||||
- Memory mode Clock eviction.
|
||||
- Memory mode active-count stale check.
|
||||
- Memory and disk frequency admission.
|
||||
- Memory and disk latency admission.
|
||||
- Disk mode fixed-slot put/get.
|
||||
- Disk mode global used-byte capacity eviction.
|
||||
- Disk mode segment-level Clock eviction.
|
||||
- Disk mode config rebuild and old `.cache` file cleanup.
|
||||
- Disk directory creation failure disables cache.
|
||||
- Disk row-count mismatch rejects unstable/growing segment usage.
|
||||
- Concurrent `SetConfig` with get/put.
|
||||
- Expression integration tests for TextMatch, JSON, Exists, Term, Range, and index/stat paths.
|
||||
- Two-stage `FilterBitsNode` tests to verify outer filter cache does not duplicate sub-expression entries.
|
||||
|
||||
## Known Limitations and Follow-Ups
|
||||
|
||||
Current limitations:
|
||||
|
||||
- Disk cache files are temporary process-local cache files and are not reused after restart.
|
||||
- Disk mode stores raw bitmaps; compression is memory-only.
|
||||
- Disk mode supports sealed segments only.
|
||||
- Cache key quality depends on expression signature stability and completeness.
|
||||
- Current metrics report usage bytes only, not hit rate or admission/eviction counts.
|
||||
|
||||
Potential follow-ups:
|
||||
|
||||
- Add hit/miss/admission/eviction counters.
|
||||
- Add memory/disk backend latency metrics.
|
||||
- Evaluate compressed disk slots if disk footprint becomes a bottleneck.
|
||||
- Introduce a structured expression signature builder to reduce reliance on hand-written `ToString()`.
|
||||
- Add an operational option to choose whether disabling cache should clear existing entries.
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include <arrow/io/interfaces.h>
|
||||
#include <arrow/io/type_fwd.h>
|
||||
@@ -123,9 +124,57 @@ SetExprResCacheEnable(bool val) {
|
||||
}
|
||||
|
||||
void
|
||||
SetExprResCacheCapacityBytes(int64_t bytes) {
|
||||
milvus::exec::ExprResCacheManager::Instance().SetCapacityBytes(
|
||||
static_cast<size_t>(bytes));
|
||||
SetExprResCacheConfig(const char* mode,
|
||||
const char* disk_base_path,
|
||||
int64_t mem_max_bytes,
|
||||
bool compression_enabled,
|
||||
int32_t admission_threshold,
|
||||
int64_t mem_min_eval_duration_us,
|
||||
int64_t disk_max_bytes,
|
||||
int64_t disk_max_file_size,
|
||||
int64_t disk_min_eval_duration_us) {
|
||||
milvus::exec::CacheConfig config;
|
||||
std::string mode_value = mode == nullptr ? "" : std::string(mode);
|
||||
if (mode_value == "disk") {
|
||||
config.mode = milvus::exec::CacheMode::Disk;
|
||||
} else if (mode_value == "memory") {
|
||||
config.mode = milvus::exec::CacheMode::Memory;
|
||||
} else {
|
||||
LOG_WARN("invalid expr result cache mode '{}', disabling cache",
|
||||
mode_value);
|
||||
milvus::exec::ExprResCacheManager::SetEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((config.mode == milvus::exec::CacheMode::Memory &&
|
||||
mem_max_bytes <= 0) ||
|
||||
(config.mode == milvus::exec::CacheMode::Disk &&
|
||||
(disk_max_bytes <= 0 || disk_max_file_size <= 0))) {
|
||||
LOG_WARN("invalid expr result cache size config, disabling cache");
|
||||
milvus::exec::ExprResCacheManager::SetEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
config.disk_base_path =
|
||||
disk_base_path == nullptr ? std::string() : std::string(disk_base_path);
|
||||
config.mem_max_bytes = static_cast<size_t>(mem_max_bytes);
|
||||
config.compression_enabled = compression_enabled;
|
||||
if (admission_threshold < 1) {
|
||||
admission_threshold = 1;
|
||||
} else if (admission_threshold > 255) {
|
||||
admission_threshold = 255;
|
||||
}
|
||||
config.admission_threshold = static_cast<uint8_t>(admission_threshold);
|
||||
config.mem_min_eval_duration_us =
|
||||
mem_min_eval_duration_us < 0 ? 0 : mem_min_eval_duration_us;
|
||||
config.disk_max_bytes = static_cast<uint64_t>(disk_max_bytes);
|
||||
config.disk_max_file_size = static_cast<uint64_t>(disk_max_file_size);
|
||||
config.disk_min_eval_duration_us =
|
||||
disk_min_eval_duration_us < 0 ? 0 : disk_min_eval_duration_us;
|
||||
|
||||
bool applied =
|
||||
milvus::exec::ExprResCacheManager::Instance().SetConfig(config);
|
||||
milvus::exec::ExprResCacheManager::SetEnabled(applied);
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
@@ -89,7 +89,15 @@ void
|
||||
SetExprResCacheEnable(bool val);
|
||||
|
||||
void
|
||||
SetExprResCacheCapacityBytes(int64_t bytes);
|
||||
SetExprResCacheConfig(const char* mode, // "memory" or "disk"
|
||||
const char* disk_base_path, // disk mode: file path
|
||||
int64_t mem_max_bytes,
|
||||
bool compression_enabled,
|
||||
int32_t admission_threshold,
|
||||
int64_t mem_min_eval_duration_us,
|
||||
int64_t disk_max_bytes,
|
||||
int64_t disk_max_file_size,
|
||||
int64_t disk_min_eval_duration_us);
|
||||
|
||||
// Set the capacity of arrow's internal IO thread pool. This pool runs
|
||||
// async range reads (ReadRangeCache) that issue actual S3 GetObject
|
||||
|
||||
@@ -652,8 +652,11 @@ PhyBinaryRangeFilterExpr::ExecRangeVisitorImplForJsonStats() {
|
||||
ValueType val1 = GetValueWithCastNumber<ValueType>(expr_->lower_val_);
|
||||
ValueType val2 = GetValueWithCastNumber<ValueType>(expr_->upper_val_);
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto* segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -812,6 +815,7 @@ PhyBinaryRangeFilterExpr::ExecRangeVisitorImplForJsonStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
// Licensed to the LF AI & Data foundation under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#include "exec/expression/CacheCompressor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include <roaring/roaring.h>
|
||||
#include <roaring/roaring.hh>
|
||||
#include <roaring/containers/bitset.h>
|
||||
#include <roaring/containers/containers.h>
|
||||
#include <roaring/roaring_array.h>
|
||||
|
||||
#include "log/Log.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
// ---- Payload header ----
|
||||
// [result_bit_count (4B)] [valid_bit_count (4B)] [payload...]
|
||||
// valid_bit_count high bit = kValidAllOnesMask means valid is all-ones (not in payload).
|
||||
//
|
||||
// For LZ4/Raw: payload = LZ4-compressed or raw bytes of result (+ valid if not all-ones).
|
||||
// For Roaring/RoaringInv: payload = [result_roaring_size (4B)][result_roaring][valid_roaring]
|
||||
// (valid_roaring is absent if valid is all-ones)
|
||||
|
||||
constexpr size_t kHeaderSize = sizeof(uint32_t) * 2;
|
||||
|
||||
// Density thresholds for auto-selection
|
||||
constexpr double kRoaringDensityMax = 0.03; // <= 3% → Roaring
|
||||
constexpr double kRoaringInvDensityMin = 0.97; // >= 97% → invert + Roaring
|
||||
constexpr double kRawDensityMin = 0.30; // 30%-70% → Raw
|
||||
constexpr double kRawDensityMax = 0.70;
|
||||
|
||||
// ---- Roaring V2 zero-copy encode ----
|
||||
|
||||
std::vector<char>
|
||||
CacheCompressor::CompressRoaring(const TargetBitmap& bset) {
|
||||
using namespace roaring::internal;
|
||||
|
||||
const uint64_t* words = reinterpret_cast<const uint64_t*>(bset.data());
|
||||
size_t total_bits = bset.size();
|
||||
size_t total_words = bset.size_in_bytes() / 8;
|
||||
|
||||
size_t num_containers = (total_bits + 65535) / 65536;
|
||||
constexpr size_t WORDS_PER_CONTAINER = 1024;
|
||||
constexpr int32_t ARRAY_THRESHOLD = 4096;
|
||||
|
||||
roaring_bitmap_t* r = roaring_bitmap_create_with_capacity(
|
||||
static_cast<uint32_t>(num_containers));
|
||||
|
||||
for (size_t c = 0; c < num_containers; ++c) {
|
||||
uint16_t key = static_cast<uint16_t>(c);
|
||||
size_t word_start = c * WORDS_PER_CONTAINER;
|
||||
size_t word_end =
|
||||
std::min(word_start + WORDS_PER_CONTAINER, total_words);
|
||||
size_t chunk_words = word_end - word_start;
|
||||
|
||||
int32_t popcount = 0;
|
||||
for (size_t w = word_start; w < word_end; ++w) {
|
||||
popcount += __builtin_popcountll(words[w]);
|
||||
}
|
||||
|
||||
if (popcount == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (popcount >= ARRAY_THRESHOLD) {
|
||||
bitset_container_t* bc = bitset_container_create();
|
||||
std::memcpy(bc->words, words + word_start, chunk_words * 8);
|
||||
if (chunk_words < WORDS_PER_CONTAINER) {
|
||||
std::memset(bc->words + chunk_words,
|
||||
0,
|
||||
(WORDS_PER_CONTAINER - chunk_words) * 8);
|
||||
}
|
||||
bc->cardinality = popcount;
|
||||
ra_append(&r->high_low_container,
|
||||
key,
|
||||
static_cast<container_t*>(bc),
|
||||
BITSET_CONTAINER_TYPE);
|
||||
} else {
|
||||
array_container_t* ac =
|
||||
array_container_create_given_capacity(popcount);
|
||||
for (size_t w = word_start; w < word_end; ++w) {
|
||||
uint64_t word = words[w];
|
||||
while (word != 0) {
|
||||
ac->array[ac->cardinality++] = static_cast<uint16_t>(
|
||||
(w - word_start) * 64 + __builtin_ctzll(word));
|
||||
word &= word - 1;
|
||||
}
|
||||
}
|
||||
ra_append(&r->high_low_container,
|
||||
key,
|
||||
static_cast<container_t*>(ac),
|
||||
ARRAY_CONTAINER_TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
// runOptimize: convert bitmap/array → run containers where smaller
|
||||
roaring_bitmap_run_optimize(r);
|
||||
|
||||
size_t ser_size = roaring_bitmap_size_in_bytes(r);
|
||||
std::vector<char> buf(ser_size);
|
||||
roaring_bitmap_serialize(r, buf.data());
|
||||
roaring_bitmap_free(r);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- Roaring decode ----
|
||||
|
||||
bool
|
||||
CacheCompressor::DecompressRoaring(const char* data,
|
||||
uint32_t data_len,
|
||||
uint32_t num_bits,
|
||||
TargetBitmap& out) {
|
||||
roaring_bitmap_t* r = roaring_bitmap_deserialize_safe(data, data_len);
|
||||
if (!r) {
|
||||
LOG_WARN("CacheCompressor::DecompressRoaring: deserialize failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
TargetBitmap result(num_bits, false);
|
||||
uint64_t card = roaring_bitmap_get_cardinality(r);
|
||||
if (card > 0) {
|
||||
// Extract all set-bit positions and set them in dense bitset
|
||||
std::vector<uint32_t> positions(card);
|
||||
roaring_bitmap_to_uint32_array(r, positions.data());
|
||||
|
||||
uint64_t* words = reinterpret_cast<uint64_t*>(result.data());
|
||||
for (uint32_t pos : positions) {
|
||||
if (pos < num_bits) {
|
||||
words[pos / 64] |= (uint64_t{1} << (pos % 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
roaring_bitmap_free(r);
|
||||
out = std::move(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
CompressedData
|
||||
CacheCompressor::Compress(const TargetBitmap& result,
|
||||
const TargetBitmap& valid,
|
||||
bool compression_enabled) {
|
||||
CompressedData out;
|
||||
const uint32_t result_bits = static_cast<uint32_t>(result.size());
|
||||
const uint32_t valid_bits = static_cast<uint32_t>(valid.size());
|
||||
const uint32_t result_bytes = static_cast<uint32_t>(result.size_in_bytes());
|
||||
|
||||
// Single popcount for result (used for density-based compression selection)
|
||||
size_t result_count =
|
||||
(compression_enabled && result.size() > 0) ? result.count() : 0;
|
||||
|
||||
// Detect valid all-ones: skip storing valid bytes if all set.
|
||||
// Uses all() which does word-level comparison with short-circuit (~5μs),
|
||||
// much faster than count() == size() which does full popcount (~15μs).
|
||||
bool valid_all_ones = valid.all();
|
||||
const uint32_t valid_bytes =
|
||||
valid_all_ones ? 0 : static_cast<uint32_t>(valid.size_in_bytes());
|
||||
const uint32_t valid_bits_header =
|
||||
valid_all_ones ? (valid_bits | kValidAllOnesMask) : valid_bits;
|
||||
|
||||
// Pre-fill header (used by all paths)
|
||||
std::memcpy(out.header, &result_bits, 4);
|
||||
std::memcpy(out.header + 4, &valid_bits_header, 4);
|
||||
|
||||
// Auto-select compression based on result density
|
||||
if (compression_enabled && result.size() > 0) {
|
||||
double density = static_cast<double>(result_count) / result.size();
|
||||
|
||||
// --- Roaring path (sparse ≤3%) ---
|
||||
if (density <= kRoaringDensityMax) {
|
||||
out.comp_type = kCompTypeRoaring;
|
||||
auto result_roaring = CompressRoaring(result);
|
||||
uint32_t rr_sz = static_cast<uint32_t>(result_roaring.size());
|
||||
|
||||
std::vector<char> valid_roaring;
|
||||
uint32_t vr_sz = 0;
|
||||
if (!valid_all_ones && valid.size() > 0) {
|
||||
valid_roaring = CompressRoaring(valid);
|
||||
vr_sz = static_cast<uint32_t>(valid_roaring.size());
|
||||
}
|
||||
out.payload.resize(4 + rr_sz + vr_sz);
|
||||
std::memcpy(out.payload.data(), &rr_sz, 4);
|
||||
std::memcpy(out.payload.data() + 4, result_roaring.data(), rr_sz);
|
||||
if (vr_sz > 0) {
|
||||
std::memcpy(out.payload.data() + 4 + rr_sz,
|
||||
valid_roaring.data(),
|
||||
vr_sz);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Inverted Roaring path (dense ≥97%) ---
|
||||
if (density >= kRoaringInvDensityMin) {
|
||||
out.comp_type = kCompTypeRoaringInv;
|
||||
TargetBitmap inverted(result.size());
|
||||
inverted.set();
|
||||
inverted -= result;
|
||||
auto result_roaring = CompressRoaring(inverted);
|
||||
uint32_t rr_sz = static_cast<uint32_t>(result_roaring.size());
|
||||
|
||||
std::vector<char> valid_roaring;
|
||||
uint32_t vr_sz = 0;
|
||||
if (!valid_all_ones && valid.size() > 0) {
|
||||
valid_roaring = CompressRoaring(valid);
|
||||
vr_sz = static_cast<uint32_t>(valid_roaring.size());
|
||||
}
|
||||
out.payload.resize(4 + rr_sz + vr_sz);
|
||||
std::memcpy(out.payload.data(), &rr_sz, 4);
|
||||
std::memcpy(out.payload.data() + 4, result_roaring.data(), rr_sz);
|
||||
if (vr_sz > 0) {
|
||||
std::memcpy(out.payload.data() + 4 + rr_sz,
|
||||
valid_roaring.data(),
|
||||
vr_sz);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mid-density (3%-97%): fall through to zero-copy Raw
|
||||
}
|
||||
|
||||
// --- Raw path: zero-copy via pointers ---
|
||||
out.comp_type = kCompTypeRaw;
|
||||
out.raw_result_ptr = reinterpret_cast<const char*>(result.data());
|
||||
out.raw_result_size = result_bytes;
|
||||
if (!valid_all_ones && valid.size() > 0) {
|
||||
out.raw_valid_ptr = reinterpret_cast<const char*>(valid.data());
|
||||
out.raw_valid_size = valid_bytes;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Backward-compat wrapper: flatten CompressedData into a single buffer
|
||||
std::vector<char>
|
||||
CacheCompressor::Compress(const TargetBitmap& result,
|
||||
const TargetBitmap& valid,
|
||||
bool compression_enabled,
|
||||
uint8_t& out_comp_type) {
|
||||
auto cd = Compress(result, valid, compression_enabled);
|
||||
out_comp_type = cd.comp_type;
|
||||
std::vector<char> buf(cd.total_size());
|
||||
std::memcpy(buf.data(), cd.header, 8);
|
||||
if (cd.comp_type == kCompTypeRaw) {
|
||||
char* p = buf.data() + 8;
|
||||
if (cd.raw_result_size > 0) {
|
||||
std::memcpy(p, cd.raw_result_ptr, cd.raw_result_size);
|
||||
p += cd.raw_result_size;
|
||||
}
|
||||
if (cd.raw_valid_size > 0) {
|
||||
std::memcpy(p, cd.raw_valid_ptr, cd.raw_valid_size);
|
||||
}
|
||||
} else {
|
||||
std::memcpy(buf.data() + 8, cd.payload.data(), cd.payload.size());
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
bool
|
||||
CacheCompressor::Decompress(const char* data,
|
||||
uint32_t data_len,
|
||||
uint8_t comp_type,
|
||||
TargetBitmap& out_result,
|
||||
TargetBitmap& out_valid) {
|
||||
if (data_len < kHeaderSize) {
|
||||
LOG_WARN("CacheCompressor::Decompress: data_len ({}) < header size",
|
||||
data_len);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t result_bits = 0;
|
||||
uint32_t valid_bits_raw = 0;
|
||||
std::memcpy(&result_bits, data, 4);
|
||||
std::memcpy(&valid_bits_raw, data + 4, 4);
|
||||
|
||||
bool valid_all_ones = (valid_bits_raw & kValidAllOnesMask) != 0;
|
||||
uint32_t valid_bits = valid_bits_raw & ~kValidAllOnesMask;
|
||||
|
||||
if (result_bits == 0 && valid_bits == 0) {
|
||||
out_result = TargetBitmap(0);
|
||||
out_valid = TargetBitmap(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* payload = data + kHeaderSize;
|
||||
const uint32_t payload_len = data_len - kHeaderSize;
|
||||
|
||||
// --- Roaring / RoaringInv path ---
|
||||
if (comp_type == kCompTypeRoaring || comp_type == kCompTypeRoaringInv) {
|
||||
if (payload_len < 4) {
|
||||
LOG_WARN("CacheCompressor::Decompress: roaring payload too short");
|
||||
return false;
|
||||
}
|
||||
uint32_t rr_sz = 0;
|
||||
std::memcpy(&rr_sz, payload, 4);
|
||||
if (rr_sz > payload_len - 4) {
|
||||
LOG_WARN(
|
||||
"CacheCompressor::Decompress: roaring result payload too short "
|
||||
"(rr_sz={}, payload_len={})",
|
||||
rr_sz,
|
||||
payload_len);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DecompressRoaring(payload + 4, rr_sz, result_bits, out_result)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (comp_type == kCompTypeRoaringInv) {
|
||||
out_result.flip();
|
||||
}
|
||||
|
||||
if (valid_all_ones) {
|
||||
out_valid = TargetBitmap(valid_bits);
|
||||
out_valid.set();
|
||||
} else if (payload_len > 4 + rr_sz) {
|
||||
if (!DecompressRoaring(payload + 4 + rr_sz,
|
||||
payload_len - 4 - rr_sz,
|
||||
valid_bits,
|
||||
out_valid)) {
|
||||
return false;
|
||||
}
|
||||
} else if (valid_bits > 0) {
|
||||
LOG_WARN(
|
||||
"CacheCompressor::Decompress: roaring valid payload missing");
|
||||
return false;
|
||||
} else {
|
||||
out_valid = TargetBitmap(valid_bits, false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Raw path ---
|
||||
if (comp_type != kCompTypeRaw) {
|
||||
LOG_WARN("CacheCompressor::Decompress: unknown comp_type={}",
|
||||
comp_type);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto bits_to_bytes = [](uint32_t bits) -> uint32_t {
|
||||
return static_cast<uint32_t>(((bits + 63) / 64) * 8);
|
||||
};
|
||||
const uint32_t result_bytes = bits_to_bytes(result_bits);
|
||||
const uint32_t valid_bytes = valid_all_ones ? 0 : bits_to_bytes(valid_bits);
|
||||
const uint32_t raw_total = result_bytes + valid_bytes;
|
||||
|
||||
if (payload_len < raw_total) {
|
||||
LOG_WARN(
|
||||
"CacheCompressor::Decompress: raw payload too short "
|
||||
"(payload_len={}, expected={})",
|
||||
payload_len,
|
||||
raw_total);
|
||||
return false;
|
||||
}
|
||||
const char* raw = payload;
|
||||
|
||||
out_result = TargetBitmap(result_bits, false);
|
||||
if (result_bytes > 0) {
|
||||
std::memcpy(
|
||||
reinterpret_cast<char*>(out_result.data()), raw, result_bytes);
|
||||
}
|
||||
|
||||
if (valid_all_ones) {
|
||||
// Construct minimal bitmap and set all bits.
|
||||
// Note: TargetBitmap(n, true) does one write pass (init to all-1s),
|
||||
// vs TargetBitmap(n) + set() which does two (zero-init then set).
|
||||
out_valid = TargetBitmap(valid_bits, true);
|
||||
} else {
|
||||
out_valid = TargetBitmap(valid_bits, false);
|
||||
if (valid_bytes > 0) {
|
||||
std::memcpy(reinterpret_cast<char*>(out_valid.data()),
|
||||
raw + result_bytes,
|
||||
valid_bytes);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
@@ -0,0 +1,104 @@
|
||||
// Licensed to the LF AI & Data foundation under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "common/Types.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
// Compression types stored in CacheEntryHeader.comp_type
|
||||
constexpr uint8_t kCompTypeLZ4 = 0;
|
||||
constexpr uint8_t kCompTypeRoaring = 1;
|
||||
constexpr uint8_t kCompTypeRoaringInv =
|
||||
2; // inverted + Roaring (density > 97%)
|
||||
constexpr uint8_t kCompTypeRaw = 0xFF;
|
||||
|
||||
// Flag in valid_bit_count high bit: valid bitset is all-ones, not stored
|
||||
constexpr uint32_t kValidAllOnesMask = 0x80000000u;
|
||||
|
||||
// Compressed data wrapper that supports both owned-buffer (Roaring) and
|
||||
// zero-copy scatter-gather (Raw) modes. Designed to avoid intermediate
|
||||
// allocations on the Raw fast path.
|
||||
struct CompressedData {
|
||||
uint8_t comp_type{kCompTypeRaw};
|
||||
|
||||
// Header (8 bytes): [result_bits][valid_bits_with_flag]
|
||||
char header[8];
|
||||
|
||||
// For Roaring/RoaringInv: full payload owned here
|
||||
std::vector<char> payload;
|
||||
|
||||
// For Raw: zero-copy pointers to original data (header still in `header`)
|
||||
const char* raw_result_ptr{nullptr};
|
||||
size_t raw_result_size{0};
|
||||
const char* raw_valid_ptr{nullptr};
|
||||
size_t raw_valid_size{0};
|
||||
|
||||
// Total entry data size on disk
|
||||
size_t
|
||||
total_size() const {
|
||||
if (comp_type == kCompTypeRaw) {
|
||||
return 8 + raw_result_size + raw_valid_size;
|
||||
}
|
||||
return 8 + payload.size();
|
||||
}
|
||||
};
|
||||
|
||||
class CacheCompressor {
|
||||
public:
|
||||
// Compress result+valid bitsets, auto-selecting the best method:
|
||||
// density <= 3% → Roaring
|
||||
// density >= 97% → inverted Roaring
|
||||
// otherwise → Raw (zero-copy)
|
||||
// Valid bitset: if all-ones, skipped entirely (flagged in header).
|
||||
static CompressedData
|
||||
Compress(const TargetBitmap& result,
|
||||
const TargetBitmap& valid,
|
||||
bool compression_enabled);
|
||||
|
||||
// Backward-compat: returns a flat buffer (header + payload).
|
||||
// Used by tests; production path uses the CompressedData variant directly.
|
||||
static std::vector<char>
|
||||
Compress(const TargetBitmap& result,
|
||||
const TargetBitmap& valid,
|
||||
bool compression_enabled,
|
||||
uint8_t& out_comp_type);
|
||||
|
||||
static bool
|
||||
Decompress(const char* data,
|
||||
uint32_t data_len,
|
||||
uint8_t comp_type,
|
||||
TargetBitmap& out_result,
|
||||
TargetBitmap& out_valid);
|
||||
|
||||
private:
|
||||
static std::vector<char>
|
||||
CompressRoaring(const TargetBitmap& bset);
|
||||
|
||||
static bool
|
||||
DecompressRoaring(const char* data,
|
||||
uint32_t data_len,
|
||||
uint32_t num_bits,
|
||||
TargetBitmap& out);
|
||||
};
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
@@ -0,0 +1,417 @@
|
||||
// Licensed to the LF AI & Data foundation under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#include "exec/expression/DiskSlotFile.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <cstring>
|
||||
|
||||
#include "log/Log.h"
|
||||
#include "xxhash.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
DiskSlotFile::DiskSlotFile(int64_t segment_id,
|
||||
const std::string& path,
|
||||
int64_t row_count,
|
||||
uint64_t max_file_size)
|
||||
: segment_id_(segment_id), path_(path), row_count_(row_count) {
|
||||
// Calculate sizes
|
||||
bitset_bytes_ = static_cast<uint32_t>(((row_count + 63) / 64) * 8);
|
||||
slot_size_ = kSlotHeaderSize + bitset_bytes_ * 2; // result + valid
|
||||
|
||||
// Need at least 1 slot
|
||||
if (max_file_size <= kFileHeaderSize + slot_size_) {
|
||||
num_slots_ = 1;
|
||||
} else {
|
||||
num_slots_ = static_cast<uint32_t>((max_file_size - kFileHeaderSize) /
|
||||
slot_size_);
|
||||
}
|
||||
|
||||
// Open file (create if not exists)
|
||||
fd_ = ::open(path_.c_str(), O_RDWR | O_CREAT, 0644);
|
||||
if (fd_ < 0) {
|
||||
LOG_ERROR(
|
||||
"DiskSlotFile: failed to open {}: {}", path_, strerror(errno));
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate file space
|
||||
size_t file_size =
|
||||
kFileHeaderSize + static_cast<size_t>(num_slots_) * slot_size_;
|
||||
if (::ftruncate(fd_, static_cast<off_t>(file_size)) != 0) {
|
||||
LOG_ERROR("DiskSlotFile: ftruncate failed for {}: {}",
|
||||
path_,
|
||||
strerror(errno));
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Write file header
|
||||
WriteFileHeader();
|
||||
|
||||
// Initialize free slots (all slots start as free)
|
||||
free_slots_.reserve(num_slots_);
|
||||
for (uint32_t i = 0; i < num_slots_; ++i) {
|
||||
free_slots_.push_back(i);
|
||||
}
|
||||
|
||||
LOG_DEBUG(
|
||||
"DiskSlotFile: created segment_id={} path={} row_count={} "
|
||||
"slot_size={} num_slots={} bitset_bytes={}",
|
||||
segment_id_,
|
||||
path_,
|
||||
row_count,
|
||||
slot_size_,
|
||||
num_slots_,
|
||||
bitset_bytes_);
|
||||
}
|
||||
|
||||
DiskSlotFile::~DiskSlotFile() {
|
||||
Close();
|
||||
}
|
||||
|
||||
void
|
||||
DiskSlotFile::WriteFileHeader() {
|
||||
FileHeader hdr{};
|
||||
hdr.magic = kMagic;
|
||||
hdr.version = kVersion;
|
||||
hdr.segment_id = segment_id_;
|
||||
hdr.slot_size = slot_size_;
|
||||
hdr.num_slots = num_slots_;
|
||||
hdr.used_count = 0;
|
||||
std::memset(hdr.reserved, 0, sizeof(hdr.reserved));
|
||||
|
||||
ssize_t written = ::pwrite(fd_, &hdr, sizeof(hdr), 0);
|
||||
if (written != sizeof(hdr)) {
|
||||
LOG_ERROR("DiskSlotFile: failed to write file header: {}",
|
||||
strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
bool
|
||||
DiskSlotFile::Get(const std::string& signature,
|
||||
int64_t active_count,
|
||||
TargetBitmap& out_result,
|
||||
TargetBitmap& out_valid) {
|
||||
std::shared_lock lock(mutex_);
|
||||
|
||||
if (fd_ < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto it = slot_index_.find(signature);
|
||||
if (it == slot_index_.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto& meta = *it->second;
|
||||
|
||||
// Staleness check
|
||||
if (meta.active_count != active_count) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bump usage_count (atomic, no lock needed — Clock touch)
|
||||
auto old = meta.usage_count.load(std::memory_order_relaxed);
|
||||
if (old < 5) {
|
||||
meta.usage_count.store(old + 1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
off_t offset =
|
||||
static_cast<off_t>(kFileHeaderSize) +
|
||||
static_cast<off_t>(meta.slot_id) * static_cast<off_t>(slot_size_);
|
||||
|
||||
SlotHeader slot_hdr;
|
||||
ssize_t bytes_read = ::pread(fd_, &slot_hdr, sizeof(SlotHeader), offset);
|
||||
if (bytes_read != static_cast<ssize_t>(sizeof(SlotHeader))) {
|
||||
LOG_ERROR("DiskSlotFile::Get: pread header failed slot_id={}: {}",
|
||||
meta.slot_id,
|
||||
strerror(errno));
|
||||
return false;
|
||||
}
|
||||
if (slot_hdr.sig_hash != meta.sig_hash) {
|
||||
LOG_ERROR("DiskSlotFile::Get: sig_hash mismatch on disk for slot_id={}",
|
||||
meta.slot_id);
|
||||
return false;
|
||||
}
|
||||
if (slot_hdr.active_count != active_count) {
|
||||
LOG_ERROR(
|
||||
"DiskSlotFile::Get: active_count mismatch on disk for slot_id={}",
|
||||
meta.slot_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
out_result = TargetBitmap(row_count_);
|
||||
bytes_read = ::pread(fd_,
|
||||
reinterpret_cast<char*>(out_result.data()),
|
||||
bitset_bytes_,
|
||||
offset + static_cast<off_t>(kSlotHeaderSize));
|
||||
if (bytes_read != static_cast<ssize_t>(bitset_bytes_)) {
|
||||
LOG_ERROR("DiskSlotFile::Get: pread result failed slot_id={}: {}",
|
||||
meta.slot_id,
|
||||
strerror(errno));
|
||||
return false;
|
||||
}
|
||||
|
||||
out_valid = TargetBitmap(row_count_);
|
||||
bytes_read =
|
||||
::pread(fd_,
|
||||
reinterpret_cast<char*>(out_valid.data()),
|
||||
bitset_bytes_,
|
||||
offset + static_cast<off_t>(kSlotHeaderSize + bitset_bytes_));
|
||||
if (bytes_read != static_cast<ssize_t>(bitset_bytes_)) {
|
||||
LOG_ERROR("DiskSlotFile::Get: pread valid failed slot_id={}: {}",
|
||||
meta.slot_id,
|
||||
strerror(errno));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void
|
||||
DiskSlotFile::Put(const std::string& signature,
|
||||
int64_t active_count,
|
||||
const TargetBitmap& result,
|
||||
const TargetBitmap& valid) {
|
||||
uint64_t sig_hash = XXH64(signature.data(), signature.size(), 0);
|
||||
|
||||
std::unique_lock lock(mutex_);
|
||||
|
||||
if (fd_ < 0) {
|
||||
return;
|
||||
}
|
||||
if (result.size() != static_cast<size_t>(row_count_) ||
|
||||
valid.size() != static_cast<size_t>(row_count_)) {
|
||||
LOG_WARN(
|
||||
"DiskSlotFile::Put: row_count mismatch, segment_id={} expected={} "
|
||||
"result_size={} valid_size={}",
|
||||
segment_id_,
|
||||
row_count_,
|
||||
result.size(),
|
||||
valid.size());
|
||||
return;
|
||||
}
|
||||
|
||||
auto existing = slot_index_.find(signature);
|
||||
if (existing != slot_index_.end()) {
|
||||
free_slots_.push_back(existing->second->slot_id);
|
||||
slot_index_.erase(existing);
|
||||
clock_dirty_ = true;
|
||||
}
|
||||
|
||||
// Allocate a slot (may evict)
|
||||
uint32_t slot_id = AllocateSlot();
|
||||
|
||||
// Build slot buffer: [SlotHeader][raw bitset data]
|
||||
std::vector<char> buf(slot_size_, 0);
|
||||
|
||||
SlotHeader slot_hdr;
|
||||
slot_hdr.sig_hash = sig_hash;
|
||||
slot_hdr.active_count = active_count;
|
||||
slot_hdr.flags = 0;
|
||||
std::memcpy(buf.data(), &slot_hdr, sizeof(SlotHeader));
|
||||
|
||||
// Copy result bitset
|
||||
size_t result_copy =
|
||||
std::min(static_cast<size_t>(bitset_bytes_), result.size_in_bytes());
|
||||
std::memcpy(buf.data() + kSlotHeaderSize,
|
||||
reinterpret_cast<const char*>(result.data()),
|
||||
result_copy);
|
||||
|
||||
// Copy valid bitset
|
||||
size_t valid_copy =
|
||||
std::min(static_cast<size_t>(bitset_bytes_), valid.size_in_bytes());
|
||||
std::memcpy(buf.data() + kSlotHeaderSize + bitset_bytes_,
|
||||
reinterpret_cast<const char*>(valid.data()),
|
||||
valid_copy);
|
||||
|
||||
// Write to disk
|
||||
off_t offset = static_cast<off_t>(kFileHeaderSize) +
|
||||
static_cast<off_t>(slot_id) * static_cast<off_t>(slot_size_);
|
||||
|
||||
ssize_t written = ::pwrite(fd_, buf.data(), slot_size_, offset);
|
||||
if (written != static_cast<ssize_t>(slot_size_)) {
|
||||
LOG_ERROR("DiskSlotFile::Put: pwrite failed slot_id={}: {}",
|
||||
slot_id,
|
||||
strerror(errno));
|
||||
// Put slot back to free list since write failed
|
||||
free_slots_.push_back(slot_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert metadata
|
||||
auto meta = std::make_unique<SlotMeta>();
|
||||
meta->slot_id = slot_id;
|
||||
meta->signature = signature;
|
||||
meta->sig_hash = sig_hash;
|
||||
meta->active_count = active_count;
|
||||
meta->usage_count.store(1, std::memory_order_relaxed);
|
||||
|
||||
slot_index_[signature] = std::move(meta);
|
||||
clock_dirty_ = true;
|
||||
|
||||
LOG_DEBUG("DiskSlotFile::Put signature={} slot_id={} active_count={}",
|
||||
signature,
|
||||
slot_id,
|
||||
active_count);
|
||||
}
|
||||
|
||||
uint32_t
|
||||
DiskSlotFile::AllocateSlot() {
|
||||
// Must be called under unique_lock
|
||||
if (!free_slots_.empty()) {
|
||||
uint32_t slot_id = free_slots_.back();
|
||||
free_slots_.pop_back();
|
||||
return slot_id;
|
||||
}
|
||||
|
||||
// No free slots — evict one via Clock
|
||||
EvictOne();
|
||||
|
||||
if (!free_slots_.empty()) {
|
||||
uint32_t slot_id = free_slots_.back();
|
||||
free_slots_.pop_back();
|
||||
return slot_id;
|
||||
}
|
||||
|
||||
// Should not happen if EvictOne works correctly, but fallback to slot 0
|
||||
LOG_ERROR("DiskSlotFile::AllocateSlot: failed to free a slot, reusing 0");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
DiskSlotFile::EvictOne() {
|
||||
// Must be called under unique_lock
|
||||
if (slot_index_.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild clock key snapshot if needed
|
||||
if (clock_dirty_) {
|
||||
RebuildClockKeys();
|
||||
}
|
||||
|
||||
if (clock_keys_.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clock sweep: scan entries, decrement usage_count, evict first with 0
|
||||
// Worst case: full scan + one more round (all entries accessed)
|
||||
size_t max_scan = clock_keys_.size() * 2;
|
||||
for (size_t i = 0; i < max_scan; ++i) {
|
||||
if (clock_hand_ >= clock_keys_.size()) {
|
||||
clock_hand_ = 0;
|
||||
}
|
||||
std::string key = clock_keys_[clock_hand_];
|
||||
auto it = slot_index_.find(key);
|
||||
if (it == slot_index_.end()) {
|
||||
// Entry was removed but clock_keys_ stale
|
||||
clock_hand_++;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto& meta = *it->second;
|
||||
auto count = meta.usage_count.load(std::memory_order_relaxed);
|
||||
if (count > 0) {
|
||||
// Give it a chance: decrement and move on
|
||||
meta.usage_count.store(count - 1, std::memory_order_relaxed);
|
||||
clock_hand_++;
|
||||
} else {
|
||||
// usage_count == 0: evict this entry
|
||||
uint32_t freed_slot = meta.slot_id;
|
||||
LOG_DEBUG("DiskSlotFile::EvictOne signature={} slot_id={}",
|
||||
key,
|
||||
freed_slot);
|
||||
|
||||
// Remove from clock_keys_ (swap with last for O(1))
|
||||
clock_keys_[clock_hand_] = clock_keys_.back();
|
||||
clock_keys_.pop_back();
|
||||
slot_index_.erase(it);
|
||||
|
||||
// Return slot to free list
|
||||
free_slots_.push_back(freed_slot);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all entries have high usage_count (all hot).
|
||||
// Force evict the one at current clock_hand_.
|
||||
if (!clock_keys_.empty()) {
|
||||
if (clock_hand_ >= clock_keys_.size()) {
|
||||
clock_hand_ = 0;
|
||||
}
|
||||
std::string key = clock_keys_[clock_hand_];
|
||||
auto it = slot_index_.find(key);
|
||||
if (it != slot_index_.end()) {
|
||||
uint32_t freed_slot = it->second->slot_id;
|
||||
clock_keys_[clock_hand_] = clock_keys_.back();
|
||||
clock_keys_.pop_back();
|
||||
slot_index_.erase(it);
|
||||
free_slots_.push_back(freed_slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
DiskSlotFile::RebuildClockKeys() {
|
||||
clock_keys_.clear();
|
||||
clock_keys_.reserve(slot_index_.size());
|
||||
for (const auto& [k, _] : slot_index_) {
|
||||
clock_keys_.push_back(k);
|
||||
}
|
||||
clock_hand_ = 0;
|
||||
clock_dirty_ = false;
|
||||
}
|
||||
|
||||
void
|
||||
DiskSlotFile::Close() {
|
||||
std::unique_lock lock(mutex_);
|
||||
if (fd_ >= 0) {
|
||||
::close(fd_);
|
||||
fd_ = -1;
|
||||
}
|
||||
slot_index_.clear();
|
||||
free_slots_.clear();
|
||||
clock_keys_.clear();
|
||||
clock_hand_ = 0;
|
||||
clock_dirty_ = true;
|
||||
}
|
||||
|
||||
uint32_t
|
||||
DiskSlotFile::GetUsedCount() const {
|
||||
std::shared_lock lock(mutex_);
|
||||
return static_cast<uint32_t>(slot_index_.size());
|
||||
}
|
||||
|
||||
bool
|
||||
DiskSlotFile::HasSignature(const std::string& signature) const {
|
||||
std::shared_lock lock(mutex_);
|
||||
return slot_index_.find(signature) != slot_index_.end();
|
||||
}
|
||||
|
||||
uint64_t
|
||||
DiskSlotFile::GetUsedBytes() const {
|
||||
std::shared_lock lock(mutex_);
|
||||
return kFileHeaderSize +
|
||||
static_cast<uint64_t>(slot_index_.size()) * slot_size_;
|
||||
}
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
@@ -0,0 +1,176 @@
|
||||
// Licensed to the LF AI & Data foundation under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "common/Types.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
// Disk-backed expression cache for one sealed segment.
|
||||
// Fixed-size slots, pread/pwrite IO, Clock eviction.
|
||||
// No compression — stores raw bitset directly.
|
||||
// Growing segments are intentionally not supported: slot size is derived from
|
||||
// the segment row_count at file creation time.
|
||||
//
|
||||
// File layout:
|
||||
// [FileHeader 64B][slot_0][slot_1]...[slot_N-1]
|
||||
//
|
||||
// Each slot has a fixed header (17B) + raw bitset data.
|
||||
// Slot size = 17 + ((row_count + 63) / 64) * 8
|
||||
// All slots in one file are the same size.
|
||||
|
||||
class DiskSlotFile {
|
||||
public:
|
||||
static constexpr uint32_t kMagic = 0x45584352; // "EXCR"
|
||||
static constexpr uint32_t kVersion = 2;
|
||||
static constexpr size_t kFileHeaderSize = 64;
|
||||
static constexpr size_t kSlotHeaderSize =
|
||||
17; // sig_hash(8) + active_count(8) + flags(1)
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct FileHeader {
|
||||
uint32_t magic;
|
||||
uint32_t version;
|
||||
int64_t segment_id;
|
||||
uint32_t slot_size;
|
||||
uint32_t num_slots;
|
||||
uint32_t used_count;
|
||||
char reserved[36];
|
||||
};
|
||||
|
||||
struct SlotHeader {
|
||||
uint64_t sig_hash;
|
||||
int64_t active_count;
|
||||
uint8_t flags; // bit 0 = valid_all_ones
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
static_assert(sizeof(FileHeader) == 64, "FileHeader must be 64 bytes");
|
||||
static_assert(sizeof(SlotHeader) == 17, "SlotHeader must be 17 bytes");
|
||||
|
||||
// Create/open a disk slot file for a sealed segment.
|
||||
// row_count determines slot size. max_file_size determines number of slots.
|
||||
DiskSlotFile(int64_t segment_id,
|
||||
const std::string& path,
|
||||
int64_t row_count,
|
||||
uint64_t max_file_size);
|
||||
|
||||
~DiskSlotFile();
|
||||
|
||||
// Non-copyable, non-movable
|
||||
DiskSlotFile(const DiskSlotFile&) = delete;
|
||||
DiskSlotFile&
|
||||
operator=(const DiskSlotFile&) = delete;
|
||||
|
||||
// Get cached bitsets. Returns false on miss/mismatch.
|
||||
bool
|
||||
Get(const std::string& signature,
|
||||
int64_t active_count,
|
||||
TargetBitmap& out_result,
|
||||
TargetBitmap& out_valid);
|
||||
|
||||
// Store raw bitsets into a slot. May evict via Clock if full.
|
||||
void
|
||||
Put(const std::string& signature,
|
||||
int64_t active_count,
|
||||
const TargetBitmap& result,
|
||||
const TargetBitmap& valid);
|
||||
|
||||
void
|
||||
Close();
|
||||
|
||||
uint32_t
|
||||
GetNumSlots() const {
|
||||
return num_slots_;
|
||||
}
|
||||
|
||||
uint32_t
|
||||
GetUsedCount() const;
|
||||
|
||||
bool
|
||||
HasSignature(const std::string& signature) const;
|
||||
|
||||
uint32_t
|
||||
GetSlotSize() const {
|
||||
return slot_size_;
|
||||
}
|
||||
|
||||
uint64_t
|
||||
GetFileSizeBytes() const {
|
||||
return kFileHeaderSize + static_cast<uint64_t>(num_slots_) * slot_size_;
|
||||
}
|
||||
|
||||
uint64_t
|
||||
GetUsedBytes() const;
|
||||
|
||||
int64_t
|
||||
GetRowCount() const {
|
||||
return row_count_;
|
||||
}
|
||||
|
||||
private:
|
||||
struct SlotMeta {
|
||||
uint32_t slot_id;
|
||||
std::string signature;
|
||||
uint64_t sig_hash;
|
||||
int64_t active_count;
|
||||
std::atomic<uint8_t> usage_count{0};
|
||||
};
|
||||
|
||||
void
|
||||
WriteFileHeader();
|
||||
|
||||
uint32_t
|
||||
AllocateSlot(); // find free or evict, returns slot_id
|
||||
|
||||
void
|
||||
EvictOne(); // Clock sweep
|
||||
|
||||
void
|
||||
RebuildClockKeys();
|
||||
|
||||
int64_t segment_id_;
|
||||
std::string path_;
|
||||
int64_t row_count_;
|
||||
int fd_{-1};
|
||||
uint32_t slot_size_{0};
|
||||
uint32_t num_slots_{0};
|
||||
uint32_t bitset_bytes_{0}; // raw bitset size = ((row_count+63)/64)*8
|
||||
|
||||
mutable std::shared_mutex mutex_;
|
||||
std::unordered_map<std::string, std::unique_ptr<SlotMeta>> slot_index_;
|
||||
std::vector<uint32_t> free_slots_; // available slot IDs
|
||||
|
||||
// Clock state
|
||||
std::vector<std::string> clock_keys_;
|
||||
uint32_t clock_hand_{0};
|
||||
bool clock_dirty_{true};
|
||||
};
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
@@ -0,0 +1,269 @@
|
||||
// Licensed to the LF AI & Data foundation under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#include "exec/expression/EntryPool.h"
|
||||
|
||||
#include "log/Log.h"
|
||||
#include "xxhash.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
void
|
||||
EntryPool::Configure(size_t max_bytes,
|
||||
bool compression_enabled,
|
||||
int64_t min_eval_duration_us) {
|
||||
std::unique_lock lock(mutex_);
|
||||
max_bytes_ = max_bytes;
|
||||
compression_enabled_ = compression_enabled;
|
||||
min_eval_duration_us_ = min_eval_duration_us;
|
||||
}
|
||||
|
||||
bool
|
||||
EntryPool::Get(int64_t segment_id,
|
||||
const std::string& signature,
|
||||
int64_t active_count,
|
||||
TargetBitmap& out_result,
|
||||
TargetBitmap& out_valid) {
|
||||
uint64_t sig_hash = XXH64(signature.data(), signature.size(), 0);
|
||||
Key key{segment_id, sig_hash, signature, active_count};
|
||||
|
||||
std::vector<char> data_copy;
|
||||
uint8_t comp_type = 0;
|
||||
{
|
||||
std::shared_lock lock(mutex_);
|
||||
auto it = entries_.find(key);
|
||||
if (it == entries_.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto& entry = *it->second;
|
||||
|
||||
// Staleness check
|
||||
if (entry.active_count != active_count) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bump usage_count (atomic, no lock needed — Clock "续命")
|
||||
auto old = entry.usage_count.load(std::memory_order_relaxed);
|
||||
if (old < 5) {
|
||||
entry.usage_count.store(old + 1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
comp_type = entry.comp_type;
|
||||
data_copy = entry.data;
|
||||
}
|
||||
|
||||
// Decompress outside the index lock. The copied compressed buffer gives the
|
||||
// payload an independent lifetime if the entry is evicted concurrently.
|
||||
return CacheCompressor::Decompress(data_copy.data(),
|
||||
static_cast<uint32_t>(data_copy.size()),
|
||||
comp_type,
|
||||
out_result,
|
||||
out_valid);
|
||||
}
|
||||
|
||||
void
|
||||
EntryPool::Put(int64_t segment_id,
|
||||
const std::string& signature,
|
||||
int64_t active_count,
|
||||
const TargetBitmap& result,
|
||||
const TargetBitmap& valid,
|
||||
int64_t eval_duration_us) {
|
||||
uint64_t sig_hash = XXH64(signature.data(), signature.size(), 0);
|
||||
Key key{segment_id, sig_hash, signature, active_count};
|
||||
|
||||
bool same_signature_cached = false;
|
||||
{
|
||||
std::shared_lock lock(mutex_);
|
||||
for (const auto& [entry_key, _] : entries_) {
|
||||
if (entry_key.segment_id == segment_id &&
|
||||
entry_key.sig_hash == sig_hash &&
|
||||
entry_key.signature == signature) {
|
||||
same_signature_cached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Latency admission: skip cheap expressions
|
||||
if (!same_signature_cached && min_eval_duration_us_ > 0 &&
|
||||
eval_duration_us > 0 && eval_duration_us < min_eval_duration_us_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compress internally
|
||||
uint8_t comp_type = 0;
|
||||
std::vector<char> compressed_data = CacheCompressor::Compress(
|
||||
result, valid, compression_enabled_, comp_type);
|
||||
|
||||
size_t entry_mem =
|
||||
sizeof(Entry) + compressed_data.capacity() + signature.capacity() * 2;
|
||||
|
||||
std::unique_lock lock(mutex_);
|
||||
|
||||
auto existing = entries_.find(key);
|
||||
if (existing != entries_.end()) {
|
||||
current_bytes_.fetch_sub(existing->second->MemoryUsage(),
|
||||
std::memory_order_relaxed);
|
||||
entries_.erase(existing);
|
||||
clock_dirty_ = true;
|
||||
}
|
||||
|
||||
// Evict until enough space
|
||||
while (current_bytes_.load(std::memory_order_relaxed) + entry_mem >
|
||||
max_bytes_) {
|
||||
if (entries_.empty()) {
|
||||
break;
|
||||
}
|
||||
EvictOne();
|
||||
}
|
||||
|
||||
// Insert
|
||||
auto entry = std::make_unique<Entry>();
|
||||
entry->key = key;
|
||||
entry->signature = signature;
|
||||
entry->active_count = active_count;
|
||||
entry->comp_type = comp_type;
|
||||
entry->data = std::move(compressed_data);
|
||||
entry->usage_count.store(1, std::memory_order_relaxed);
|
||||
|
||||
current_bytes_.fetch_add(entry->MemoryUsage(), std::memory_order_relaxed);
|
||||
entries_[key] = std::move(entry);
|
||||
clock_dirty_ = true; // clock_keys_ needs rebuild
|
||||
}
|
||||
|
||||
size_t
|
||||
EntryPool::EraseSegment(int64_t segment_id) {
|
||||
std::unique_lock lock(mutex_);
|
||||
size_t erased = 0;
|
||||
|
||||
for (auto it = entries_.begin(); it != entries_.end();) {
|
||||
if (it->first.segment_id == segment_id) {
|
||||
current_bytes_.fetch_sub(it->second->MemoryUsage(),
|
||||
std::memory_order_relaxed);
|
||||
it = entries_.erase(it);
|
||||
++erased;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
if (erased > 0) {
|
||||
clock_dirty_ = true;
|
||||
}
|
||||
return erased;
|
||||
}
|
||||
|
||||
bool
|
||||
EntryPool::HasSignature(int64_t segment_id,
|
||||
const std::string& signature) const {
|
||||
uint64_t sig_hash = XXH64(signature.data(), signature.size(), 0);
|
||||
std::shared_lock lock(mutex_);
|
||||
for (const auto& [entry_key, _] : entries_) {
|
||||
if (entry_key.segment_id == segment_id &&
|
||||
entry_key.sig_hash == sig_hash &&
|
||||
entry_key.signature == signature) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void
|
||||
EntryPool::Clear() {
|
||||
std::unique_lock lock(mutex_);
|
||||
entries_.clear();
|
||||
clock_keys_.clear();
|
||||
clock_hand_ = 0;
|
||||
clock_dirty_ = true;
|
||||
current_bytes_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void
|
||||
EntryPool::EvictOne() {
|
||||
// Must be called under unique_lock
|
||||
if (entries_.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild clock key snapshot if needed
|
||||
if (clock_dirty_) {
|
||||
RebuildClockKeys();
|
||||
}
|
||||
|
||||
if (clock_keys_.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clock sweep: scan entries, decrement usage_count, evict first with 0
|
||||
// Worst case: full scan + one more round (all entries accessed)
|
||||
size_t max_scan = clock_keys_.size() * 2;
|
||||
for (size_t i = 0; i < max_scan; ++i) {
|
||||
if (clock_hand_ >= clock_keys_.size()) {
|
||||
clock_hand_ = 0;
|
||||
}
|
||||
const auto& key = clock_keys_[clock_hand_];
|
||||
auto it = entries_.find(key);
|
||||
if (it == entries_.end()) {
|
||||
// Entry was removed (e.g. by EraseSegment) but clock_keys_ stale
|
||||
clock_hand_++;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto& entry = *it->second;
|
||||
auto count = entry.usage_count.load(std::memory_order_relaxed);
|
||||
if (count > 0) {
|
||||
// Give it a chance: decrement and move on
|
||||
entry.usage_count.store(count - 1, std::memory_order_relaxed);
|
||||
clock_hand_++;
|
||||
} else {
|
||||
// usage_count == 0: evict this entry
|
||||
current_bytes_.fetch_sub(entry.MemoryUsage(),
|
||||
std::memory_order_relaxed);
|
||||
LOG_DEBUG("EntryPool::EvictOne segment_id={}, sig_hash={}",
|
||||
key.segment_id,
|
||||
key.sig_hash);
|
||||
|
||||
// Remove from clock_keys_ (swap with last for O(1))
|
||||
clock_keys_[clock_hand_] = clock_keys_.back();
|
||||
clock_keys_.pop_back();
|
||||
entries_.erase(it);
|
||||
// Don't increment clock_hand_ — the swapped-in key is now here
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all entries have high usage_count (all hot).
|
||||
// Force evict the one at current clock_hand_.
|
||||
if (!clock_keys_.empty()) {
|
||||
if (clock_hand_ >= clock_keys_.size()) {
|
||||
clock_hand_ = 0;
|
||||
}
|
||||
const auto& key = clock_keys_[clock_hand_];
|
||||
auto it = entries_.find(key);
|
||||
if (it != entries_.end()) {
|
||||
current_bytes_.fetch_sub(it->second->MemoryUsage(),
|
||||
std::memory_order_relaxed);
|
||||
clock_keys_[clock_hand_] = clock_keys_.back();
|
||||
clock_keys_.pop_back();
|
||||
entries_.erase(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
@@ -0,0 +1,184 @@
|
||||
// Licensed to the LF AI & Data foundation under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "common/Types.h"
|
||||
#include "exec/expression/CacheCompressor.h"
|
||||
#include "exec/expression/ExprCache.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
// Pure in-memory expression result cache with Clock eviction.
|
||||
//
|
||||
// Stores compressed bitset entries in heap memory (malloc-managed).
|
||||
// Uses Clock algorithm for eviction — near-LRU quality with zero
|
||||
// Get-path lock overhead (only one atomic store per access).
|
||||
//
|
||||
// Thread safety:
|
||||
// - Get: shared_lock(index) + atomic usage_count update (no write lock)
|
||||
// - Put: unique_lock(index) + potential Clock eviction
|
||||
// - EraseSegment: unique_lock(index)
|
||||
//
|
||||
// Memory management:
|
||||
// - Each entry owns a std::vector<char> for compressed data
|
||||
// - Fragmentation handled by jemalloc/tcmalloc (Milvus's default allocator)
|
||||
// - Total memory tracked by current_bytes_ atomic counter
|
||||
|
||||
class EntryPool {
|
||||
public:
|
||||
struct Key {
|
||||
int64_t segment_id{0};
|
||||
uint64_t sig_hash{0};
|
||||
std::string signature;
|
||||
int64_t active_count{0};
|
||||
|
||||
bool
|
||||
operator==(const Key& other) const {
|
||||
return segment_id == other.segment_id &&
|
||||
sig_hash == other.sig_hash && signature == other.signature &&
|
||||
active_count == other.active_count;
|
||||
}
|
||||
};
|
||||
|
||||
struct KeyHasher {
|
||||
size_t
|
||||
operator()(const Key& k) const noexcept {
|
||||
return std::hash<int64_t>()(k.segment_id) * 1315423911u ^
|
||||
std::hash<uint64_t>()(k.sig_hash) ^
|
||||
std::hash<std::string>()(k.signature) ^
|
||||
std::hash<int64_t>()(k.active_count);
|
||||
}
|
||||
};
|
||||
|
||||
struct Entry {
|
||||
Key key;
|
||||
std::string signature; // full expression string for exact match
|
||||
int64_t active_count{0}; // staleness check
|
||||
uint8_t comp_type{0};
|
||||
std::vector<char> data; // compressed payload (header + body)
|
||||
std::atomic<uint8_t> usage_count{0}; // Clock: 0-5, accessed → ++
|
||||
|
||||
size_t
|
||||
MemoryUsage() const {
|
||||
return sizeof(Entry) + data.capacity() + signature.capacity() +
|
||||
key.signature.capacity();
|
||||
}
|
||||
};
|
||||
|
||||
explicit EntryPool(size_t max_bytes) : max_bytes_(max_bytes) {
|
||||
}
|
||||
|
||||
~EntryPool() = default;
|
||||
|
||||
// Configure pool parameters. Can be called after construction to
|
||||
// update settings (e.g., from paramtable config reload).
|
||||
void
|
||||
Configure(size_t max_bytes,
|
||||
bool compression_enabled,
|
||||
int64_t min_eval_duration_us);
|
||||
|
||||
// Try to get a cached entry. On hit, fills out_result.
|
||||
// If out_valid_all_ones is set to true, out_valid is NOT filled (caller
|
||||
// should treat valid as all-ones, saving ~30μs of bitmap construction).
|
||||
// Returns false on miss, signature mismatch, or staleness mismatch.
|
||||
bool
|
||||
Get(int64_t segment_id,
|
||||
const std::string& signature,
|
||||
int64_t active_count,
|
||||
TargetBitmap& out_result,
|
||||
TargetBitmap& out_valid);
|
||||
|
||||
// Insert a compressed entry. Compression is done internally.
|
||||
// May trigger Clock eviction if over capacity.
|
||||
// Subject to frequency and latency admission control.
|
||||
void
|
||||
Put(int64_t segment_id,
|
||||
const std::string& signature,
|
||||
int64_t active_count,
|
||||
const TargetBitmap& result,
|
||||
const TargetBitmap& valid,
|
||||
int64_t eval_duration_us = 0);
|
||||
|
||||
// Erase all entries belonging to a segment. Returns number erased.
|
||||
size_t
|
||||
EraseSegment(int64_t segment_id);
|
||||
|
||||
bool
|
||||
HasSignature(int64_t segment_id, const std::string& signature) const;
|
||||
|
||||
// Clear all entries.
|
||||
void
|
||||
Clear();
|
||||
|
||||
size_t
|
||||
GetCurrentBytes() const {
|
||||
return current_bytes_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
size_t
|
||||
GetEntryCount() const {
|
||||
std::shared_lock lock(mutex_);
|
||||
return entries_.size();
|
||||
}
|
||||
|
||||
private:
|
||||
// Clock sweep: find and evict one entry with usage_count == 0.
|
||||
// Entries with usage_count > 0 get decremented (one "chance" per sweep).
|
||||
// Must be called under unique_lock.
|
||||
void
|
||||
EvictOne();
|
||||
|
||||
size_t max_bytes_;
|
||||
std::atomic<size_t> current_bytes_{0};
|
||||
|
||||
int64_t min_eval_duration_us_{0};
|
||||
bool compression_enabled_{true};
|
||||
|
||||
mutable std::shared_mutex mutex_;
|
||||
std::unordered_map<Key, std::unique_ptr<Entry>, KeyHasher> entries_;
|
||||
|
||||
// Clock state: we iterate over entries_ using a persistent iterator
|
||||
// position. Since unordered_map iteration order is stable between
|
||||
// modifications, we track position as an index into a separate vector.
|
||||
// Rebuilt lazily when entries are added/removed.
|
||||
std::vector<Key> clock_keys_; // snapshot of keys for clock sweep
|
||||
size_t clock_hand_{0};
|
||||
bool clock_dirty_{true}; // true when clock_keys_ needs rebuild
|
||||
|
||||
void
|
||||
RebuildClockKeys() {
|
||||
clock_keys_.clear();
|
||||
clock_keys_.reserve(entries_.size());
|
||||
for (const auto& [k, _] : entries_) {
|
||||
clock_keys_.push_back(k);
|
||||
}
|
||||
clock_hand_ = 0;
|
||||
clock_dirty_ = false;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
@@ -19,6 +19,7 @@
|
||||
#include <set>
|
||||
|
||||
#include "bitset/bitset.h"
|
||||
#include "exec/expression/ExprCacheHelper.h"
|
||||
#include "common/EasyAssert.h"
|
||||
#include "common/Json.h"
|
||||
#include "common/JsonCastType.h"
|
||||
@@ -89,25 +90,37 @@ PhyExistsFilterExpr::EvalJsonExistsForIndex() {
|
||||
|
||||
if (cached_index_chunk_id_ != 0) {
|
||||
cached_index_chunk_id_ = 0;
|
||||
auto pointer = milvus::Json::pointer(expr_->column_.nested_path_);
|
||||
auto* index = pinned_index_[cached_index_chunk_id_].get();
|
||||
AssertInfo(
|
||||
index != nullptr, "Cannot find json index with path: {}", pointer);
|
||||
|
||||
if (index->GetCastType().data_type() == JsonCastType::DataType::JSON) {
|
||||
// JsonFlatIndex needs special handling via executor.
|
||||
auto* json_flat_index = const_cast<index::JsonFlatIndex*>(
|
||||
dynamic_cast<const index::JsonFlatIndex*>(index));
|
||||
auto executor = json_flat_index->create_executor<double>(pointer);
|
||||
cached_index_chunk_res_ =
|
||||
std::make_shared<TargetBitmap>(executor->Exists());
|
||||
} else {
|
||||
// All other JSON path indexes (Inverted, Sort, Bitmap, Hybrid)
|
||||
// return a fresh clone from Exists().
|
||||
auto* mutable_index = const_cast<index::IndexBase*>(index);
|
||||
cached_index_chunk_res_ =
|
||||
std::make_shared<TargetBitmap>(mutable_index->Exists());
|
||||
}
|
||||
// Use ExprResCache for the full-segment bitset.
|
||||
auto cached = ExprCacheHelper::GetOrCompute(
|
||||
segment_,
|
||||
this->ToString(),
|
||||
active_count_,
|
||||
[&]() -> ExprCacheHelper::ComputeResult {
|
||||
auto pointer =
|
||||
milvus::Json::pointer(expr_->column_.nested_path_);
|
||||
auto* index = pinned_index_[cached_index_chunk_id_].get();
|
||||
AssertInfo(index != nullptr,
|
||||
"Cannot find json index with path: " + pointer);
|
||||
TargetBitmap res;
|
||||
if (index->GetCastType().data_type() ==
|
||||
JsonCastType::DataType::JSON) {
|
||||
// JsonFlatIndex needs special handling via executor.
|
||||
auto* json_flat_index = const_cast<index::JsonFlatIndex*>(
|
||||
dynamic_cast<const index::JsonFlatIndex*>(index));
|
||||
auto executor =
|
||||
json_flat_index->create_executor<double>(pointer);
|
||||
res = executor->Exists();
|
||||
} else {
|
||||
// All other JSON path indexes (Inverted, Sort, Bitmap,
|
||||
// Hybrid) return a fresh clone from Exists().
|
||||
auto* mutable_index = const_cast<index::IndexBase*>(index);
|
||||
res = mutable_index->Exists();
|
||||
}
|
||||
TargetBitmap valid(res.size(), true);
|
||||
return {std::move(res), std::move(valid)};
|
||||
});
|
||||
cached_index_chunk_res_ = cached.result;
|
||||
}
|
||||
auto res = MoveOrSliceBitmap(
|
||||
*cached_index_chunk_res_, current_index_chunk_pos_, real_batch_size);
|
||||
@@ -209,46 +222,57 @@ PhyExistsFilterExpr::EvalJsonExistsForDataSegmentByStats() {
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
cached_index_chunk_id_ = 0;
|
||||
auto segment = static_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
Assert(index.get() != nullptr);
|
||||
|
||||
cached_index_chunk_res_ = std::make_shared<TargetBitmap>(active_count_);
|
||||
TargetBitmapView res_view(*cached_index_chunk_res_);
|
||||
auto cached = ExprCacheHelper::GetOrCompute(
|
||||
segment_,
|
||||
this->ToString(),
|
||||
active_count_,
|
||||
[&]() -> ExprCacheHelper::ComputeResult {
|
||||
auto segment =
|
||||
static_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
Assert(index.get() != nullptr);
|
||||
|
||||
// process shredding data, for exists, we only need to check the fields
|
||||
// that start with the given prefix which contains the given pointer
|
||||
{
|
||||
milvus::ScopedTimer timer(
|
||||
"exists_json_stats_shredding_data",
|
||||
[this](double us) { json_stats_shredding_latency_us_ += us; });
|
||||
TargetBitmap res(active_count_);
|
||||
TargetBitmapView res_view(res);
|
||||
|
||||
auto shredding_fields =
|
||||
index->GetShreddingFieldsWithPrefix(pointer);
|
||||
for (const auto& field : shredding_fields) {
|
||||
TargetBitmap temp_valid(active_count_, true);
|
||||
TargetBitmapView temp_valid_view(temp_valid);
|
||||
index->ExecutorForGettingValid(op_ctx_, field, temp_valid_view);
|
||||
res_view |= temp_valid_view;
|
||||
}
|
||||
}
|
||||
// process shredding data
|
||||
{
|
||||
milvus::ScopedTimer timer(
|
||||
"exists_json_stats_shredding_data", [this](double us) {
|
||||
json_stats_shredding_latency_us_ += us;
|
||||
});
|
||||
auto shredding_fields =
|
||||
index->GetShreddingFieldsWithPrefix(pointer);
|
||||
for (const auto& field : shredding_fields) {
|
||||
TargetBitmap temp_valid(active_count_, true);
|
||||
TargetBitmapView temp_valid_view(temp_valid);
|
||||
index->ExecutorForGettingValid(
|
||||
op_ctx_, field, temp_valid_view);
|
||||
res_view |= temp_valid_view;
|
||||
}
|
||||
}
|
||||
|
||||
// process shared data, need to check if the value is empty
|
||||
// which match the semantics of exists in Json.h
|
||||
{
|
||||
milvus::ScopedTimer timer(
|
||||
"exists_json_stats_shared_data",
|
||||
[this](double us) { json_stats_shared_latency_us_ += us; });
|
||||
// process shared data
|
||||
{
|
||||
milvus::ScopedTimer timer(
|
||||
"exists_json_stats_shared_data", [this](double us) {
|
||||
json_stats_shared_latency_us_ += us;
|
||||
});
|
||||
index->ExecuteForSharedData(
|
||||
op_ctx_,
|
||||
bson_index_,
|
||||
pointer,
|
||||
[&](BsonView bson, uint32_t row_id, uint32_t offset) {
|
||||
res_view[row_id] = !bson.IsBsonValueEmpty(offset);
|
||||
});
|
||||
}
|
||||
|
||||
index->ExecuteForSharedData(
|
||||
op_ctx_,
|
||||
bson_index_,
|
||||
pointer,
|
||||
[&](BsonView bson, uint32_t row_id, uint32_t offset) {
|
||||
res_view[row_id] = !bson.IsBsonValueEmpty(offset);
|
||||
});
|
||||
}
|
||||
TargetBitmap valid(active_count_, true);
|
||||
return {std::move(res), std::move(valid)};
|
||||
});
|
||||
cached_index_chunk_res_ = cached.result;
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
@@ -29,6 +30,7 @@
|
||||
#include "common/OpContext.h"
|
||||
#include "common/Types.h"
|
||||
#include "exec/expression/EvalCtx.h"
|
||||
#include "exec/expression/ExprCacheHelper.h"
|
||||
#include "exec/expression/Utils.h"
|
||||
#include "exec/QueryContext.h"
|
||||
#include "expr/ITypeExpr.h"
|
||||
@@ -408,6 +410,65 @@ class SegmentExpr : public Expr {
|
||||
ApplyValidMask(valid_data, res, valid_res, size);
|
||||
}
|
||||
|
||||
// Try to load the full bitset from ExprResCache.
|
||||
// Returns true if cache hit (cached_index_chunk_res_ populated).
|
||||
// Call at the top of ByStats / ByIndex methods to skip computation.
|
||||
bool
|
||||
TryCacheGet() {
|
||||
if (!ExprResCacheManager::IsEnabled() || segment_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (ExprResCacheManager::Instance().GetMode() == CacheMode::Disk &&
|
||||
segment_->type() != SegmentType::Sealed) {
|
||||
return false;
|
||||
}
|
||||
ExprResCacheManager::Key key{segment_->get_segment_id(),
|
||||
this->ToString()};
|
||||
ExprResCacheManager::Value got;
|
||||
got.active_count = active_count_;
|
||||
if (ExprResCacheManager::Instance().Get(key, got)) {
|
||||
cached_index_chunk_res_ = got.result;
|
||||
cached_index_chunk_valid_res_ = got.valid_result;
|
||||
cached_index_chunk_id_ = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Put the current cached_index_chunk_res_ into ExprResCache.
|
||||
// Call after full bitset computation completes.
|
||||
void
|
||||
CachePut(int64_t eval_duration_us) {
|
||||
if (!ExprResCacheManager::IsEnabled() || segment_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (ExprResCacheManager::Instance().GetMode() == CacheMode::Disk &&
|
||||
segment_->type() != SegmentType::Sealed) {
|
||||
return;
|
||||
}
|
||||
if (!cached_index_chunk_res_ || !cached_index_chunk_valid_res_) {
|
||||
return;
|
||||
}
|
||||
ExprResCacheManager::Key key{segment_->get_segment_id(),
|
||||
this->ToString()};
|
||||
ExprResCacheManager::Value v;
|
||||
v.result = cached_index_chunk_res_;
|
||||
v.valid_result = cached_index_chunk_valid_res_;
|
||||
v.active_count = active_count_;
|
||||
v.eval_duration_us = eval_duration_us;
|
||||
ExprResCacheManager::Instance().Put(key, v);
|
||||
}
|
||||
|
||||
using CacheClock = std::chrono::steady_clock;
|
||||
|
||||
static int64_t
|
||||
CacheElapsedUs(CacheClock::time_point start) {
|
||||
auto elapsed_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
CacheClock::now() - start)
|
||||
.count();
|
||||
return std::max<int64_t>(elapsed_us, 1);
|
||||
}
|
||||
|
||||
int64_t
|
||||
GetNextBatchSize() {
|
||||
auto current_chunk =
|
||||
@@ -1609,47 +1670,59 @@ class SegmentExpr : public Expr {
|
||||
if (cached_index_chunk_id_ != 0) {
|
||||
Index* index_ptr = nullptr;
|
||||
PinWrapper<const index::IndexBase*> json_pw;
|
||||
// Executor for JsonFlatIndex. Must outlive index_ptr. Only used for JSON type.
|
||||
std::shared_ptr<index::JsonFlatIndexQueryExecutor<IndexInnerType>>
|
||||
executor;
|
||||
|
||||
if (field_type_ == DataType::JSON) {
|
||||
auto pointer = milvus::Json::pointer(nested_path_);
|
||||
json_pw = pinned_index_[0];
|
||||
auto json_flat_index =
|
||||
dynamic_cast<const index::JsonFlatIndex*>(json_pw.get());
|
||||
|
||||
if (json_flat_index) {
|
||||
auto index_path = json_flat_index->GetNestedPath();
|
||||
executor = json_flat_index
|
||||
->template create_executor<IndexInnerType>(
|
||||
pointer.substr(index_path.size()));
|
||||
index_ptr = executor.get();
|
||||
} else {
|
||||
auto json_index =
|
||||
const_cast<index::IndexBase*>(json_pw.get());
|
||||
index_ptr = dynamic_cast<Index*>(json_index);
|
||||
auto prepare_index = [&]() {
|
||||
if (index_ptr != nullptr) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
auto scalar_index =
|
||||
dynamic_cast<const Index*>(pinned_index_[0].get());
|
||||
index_ptr = const_cast<Index*>(scalar_index);
|
||||
}
|
||||
if (field_type_ == DataType::JSON) {
|
||||
auto pointer = milvus::Json::pointer(nested_path_);
|
||||
json_pw = pinned_index_[0];
|
||||
auto json_flat_index =
|
||||
dynamic_cast<const index::JsonFlatIndex*>(
|
||||
json_pw.get());
|
||||
|
||||
cached_index_chunk_res_ = std::make_shared<TargetBitmap>(
|
||||
std::move(func(index_ptr, values...)));
|
||||
cached_index_chunk_id_ = 0;
|
||||
if (json_flat_index) {
|
||||
auto index_path = json_flat_index->GetNestedPath();
|
||||
executor =
|
||||
json_flat_index
|
||||
->template create_executor<IndexInnerType>(
|
||||
pointer.substr(index_path.size()));
|
||||
index_ptr = executor.get();
|
||||
} else {
|
||||
auto json_index =
|
||||
const_cast<index::IndexBase*>(json_pw.get());
|
||||
index_ptr = dynamic_cast<Index*>(json_index);
|
||||
}
|
||||
} else {
|
||||
auto scalar_index =
|
||||
dynamic_cast<const Index*>(pinned_index_[0].get());
|
||||
index_ptr = const_cast<Index*>(scalar_index);
|
||||
}
|
||||
};
|
||||
prepare_index();
|
||||
cached_is_nested_index_ = index_ptr->IsNestedIndex();
|
||||
|
||||
if (cached_is_nested_index_ && func_returns_row_level) {
|
||||
// TODO(SpadeA): now, nested index is only supported for Struct which
|
||||
// does not support null now.
|
||||
cached_index_chunk_valid_res_ =
|
||||
std::make_shared<TargetBitmap>(active_count_, true);
|
||||
} else {
|
||||
cached_index_chunk_valid_res_ =
|
||||
std::make_shared<TargetBitmap>(index_ptr->IsNotNull());
|
||||
}
|
||||
auto cached = ExprCacheHelper::GetOrCompute(
|
||||
segment_,
|
||||
this->ToString(),
|
||||
active_count_,
|
||||
[&]() -> ExprCacheHelper::ComputeResult {
|
||||
prepare_index();
|
||||
TargetBitmap res = func(index_ptr, values...);
|
||||
|
||||
TargetBitmap valid_res;
|
||||
if (cached_is_nested_index_ && func_returns_row_level) {
|
||||
valid_res = TargetBitmap(active_count_, true);
|
||||
} else {
|
||||
valid_res = index_ptr->IsNotNull();
|
||||
}
|
||||
return {std::move(res), std::move(valid_res)};
|
||||
});
|
||||
cached_index_chunk_res_ = cached.result;
|
||||
cached_index_chunk_valid_res_ = cached.valid;
|
||||
cached_index_chunk_id_ = 0;
|
||||
}
|
||||
|
||||
TargetBitmap result;
|
||||
|
||||
@@ -16,11 +16,73 @@
|
||||
|
||||
#include "exec/expression/ExprCache.h"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "cachinglayer/Metrics.h"
|
||||
#include "exec/expression/DiskSlotFile.h"
|
||||
#include "exec/expression/EntryPool.h"
|
||||
#include "xxhash.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
namespace {
|
||||
|
||||
void
|
||||
RemoveCacheFilesInDir(const std::string& base_path) {
|
||||
if (base_path.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(base_path, ec) || ec) {
|
||||
if (ec) {
|
||||
LOG_WARN("ExprResCacheManager: failed to stat cache dir {}: {}",
|
||||
base_path,
|
||||
ec.message());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
std::filesystem::directory_iterator it(base_path, ec);
|
||||
std::filesystem::directory_iterator end;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
auto& entry = *it;
|
||||
if (entry.path().extension() == ".cache") {
|
||||
std::filesystem::remove(entry.path(), ec);
|
||||
if (ec) {
|
||||
LOG_WARN(
|
||||
"ExprResCacheManager: failed to remove cache file {}: {}",
|
||||
entry.path().string(),
|
||||
ec.message());
|
||||
ec.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ec) {
|
||||
LOG_WARN("ExprResCacheManager: failed to iterate cache dir {}: {}",
|
||||
base_path,
|
||||
ec.message());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::atomic<bool> ExprResCacheManager::enabled_{false};
|
||||
|
||||
namespace {
|
||||
|
||||
void
|
||||
UpdateGauge(prometheus::Gauge& gauge, int64_t delta) {
|
||||
if (delta > 0) {
|
||||
gauge.Increment(delta);
|
||||
} else if (delta < 0) {
|
||||
gauge.Decrement(-delta);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExprResCacheManager&
|
||||
ExprResCacheManager::Instance() {
|
||||
static ExprResCacheManager instance;
|
||||
@@ -37,25 +99,182 @@ ExprResCacheManager::IsEnabled() {
|
||||
return enabled_.load();
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::Init(size_t capacity_bytes, bool enabled) {
|
||||
SetEnabled(enabled);
|
||||
// capacity_bytes is kept for compatibility but not used directly
|
||||
}
|
||||
|
||||
bool
|
||||
ExprResCacheManager::SetConfig(const CacheConfig& config) {
|
||||
std::unique_lock state_lock(state_mutex_);
|
||||
const auto old_mode = config_.mode;
|
||||
const auto old_disk_base_path = config_.disk_base_path;
|
||||
|
||||
if (config.mode == CacheMode::Disk && !config.disk_base_path.empty()) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(config.disk_base_path, ec);
|
||||
if (ec) {
|
||||
LOG_WARN("ExprResCacheManager: failed to create cache dir {}: {}",
|
||||
config.disk_base_path,
|
||||
ec.message());
|
||||
SetEnabled(false);
|
||||
entry_pool_.reset();
|
||||
{
|
||||
std::unique_lock lock(disk_files_mutex_);
|
||||
disk_files_.clear();
|
||||
disk_ineligible_segments_.clear();
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(disk_clock_mutex_);
|
||||
disk_clock_segments_.clear();
|
||||
disk_clock_index_.clear();
|
||||
disk_segment_usage_.clear();
|
||||
disk_clock_hand_ = 0;
|
||||
}
|
||||
SyncUsageMetrics(0, 0);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
config_ = config;
|
||||
frequency_tracker_.Reset();
|
||||
if (config_.mode == CacheMode::Memory) {
|
||||
entry_pool_ = std::make_unique<EntryPool>(config_.mem_max_bytes);
|
||||
entry_pool_->Configure(config_.mem_max_bytes,
|
||||
config_.compression_enabled,
|
||||
config_.mem_min_eval_duration_us);
|
||||
{
|
||||
std::unique_lock lock(disk_files_mutex_);
|
||||
disk_files_.clear();
|
||||
disk_ineligible_segments_.clear();
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(disk_clock_mutex_);
|
||||
disk_clock_segments_.clear();
|
||||
disk_clock_index_.clear();
|
||||
disk_segment_usage_.clear();
|
||||
disk_clock_hand_ = 0;
|
||||
}
|
||||
if (old_mode == CacheMode::Disk) {
|
||||
RemoveCacheFilesInDir(old_disk_base_path);
|
||||
}
|
||||
SyncUsageMetrics(0, 0);
|
||||
return true;
|
||||
} else {
|
||||
entry_pool_.reset();
|
||||
{
|
||||
std::unique_lock lock(disk_files_mutex_);
|
||||
disk_files_.clear();
|
||||
disk_ineligible_segments_.clear();
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(disk_clock_mutex_);
|
||||
disk_clock_segments_.clear();
|
||||
disk_clock_index_.clear();
|
||||
disk_segment_usage_.clear();
|
||||
disk_clock_hand_ = 0;
|
||||
}
|
||||
// Disk cache metadata is process-local; old files are not reusable.
|
||||
if (!config_.disk_base_path.empty()) {
|
||||
RemoveCacheFilesInDir(config_.disk_base_path);
|
||||
}
|
||||
if (old_mode == CacheMode::Disk && !old_disk_base_path.empty() &&
|
||||
old_disk_base_path != config_.disk_base_path) {
|
||||
RemoveCacheFilesInDir(old_disk_base_path);
|
||||
}
|
||||
SyncUsageMetrics(0, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
CacheMode
|
||||
ExprResCacheManager::GetMode() const {
|
||||
std::shared_lock state_lock(state_mutex_);
|
||||
return config_.mode;
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::SetDiskConfig(const std::string& base_path,
|
||||
uint64_t max_total_size,
|
||||
uint64_t max_segment_file_size,
|
||||
bool compression_enabled,
|
||||
uint8_t admission_threshold,
|
||||
int64_t min_eval_duration_us,
|
||||
bool in_memory) {
|
||||
// Map old API to new CacheConfig.
|
||||
// in_memory=true → Memory mode; in_memory=false → still uses Memory mode
|
||||
// (to maintain backward compatibility: old callers that pass in_memory=false
|
||||
// were using mmap files, but now we route them through Memory mode since
|
||||
// SegmentCacheFile is removed. Disk mode is only via SetConfig.)
|
||||
CacheConfig cfg;
|
||||
cfg.mode = CacheMode::Memory;
|
||||
cfg.mem_max_bytes = max_total_size;
|
||||
cfg.compression_enabled = compression_enabled;
|
||||
cfg.admission_threshold = admission_threshold;
|
||||
cfg.mem_min_eval_duration_us = min_eval_duration_us;
|
||||
SetConfig(cfg);
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::SetCapacityBytes(size_t capacity_bytes) {
|
||||
capacity_bytes_.store(capacity_bytes);
|
||||
EnsureCapacity();
|
||||
std::unique_lock state_lock(state_mutex_);
|
||||
// Backward compatibility: ensure memory-mode EntryPool exists.
|
||||
// Old callers used SetCapacityBytes to configure the cache size;
|
||||
// the V2 manager needs an EntryPool to actually store entries.
|
||||
// Use threshold=1 and min_eval_duration_us=0 (no admission control)
|
||||
// to match the old manager's unconditional caching behavior.
|
||||
config_.mode = CacheMode::Memory;
|
||||
config_.mem_max_bytes = capacity_bytes;
|
||||
config_.admission_threshold = 1;
|
||||
config_.mem_min_eval_duration_us = 0;
|
||||
if (!entry_pool_) {
|
||||
entry_pool_ = std::make_unique<EntryPool>(capacity_bytes);
|
||||
}
|
||||
entry_pool_->Configure(capacity_bytes,
|
||||
config_.compression_enabled,
|
||||
config_.mem_min_eval_duration_us);
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::GetCapacityBytes() const {
|
||||
return capacity_bytes_.load();
|
||||
std::shared_lock state_lock(state_mutex_);
|
||||
if (config_.mode == CacheMode::Memory) {
|
||||
return config_.mem_max_bytes;
|
||||
}
|
||||
return config_.disk_max_bytes;
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::GetCurrentBytes() const {
|
||||
return current_bytes_.load();
|
||||
std::shared_lock state_lock(state_mutex_);
|
||||
if (config_.mode == CacheMode::Memory && entry_pool_) {
|
||||
return entry_pool_->GetCurrentBytes();
|
||||
}
|
||||
if (config_.mode == CacheMode::Disk) {
|
||||
std::shared_lock lock(disk_files_mutex_);
|
||||
return GetDiskCurrentBytesLocked();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::GetEntryCount() const {
|
||||
return concurrent_map_.size();
|
||||
std::shared_lock state_lock(state_mutex_);
|
||||
if (config_.mode == CacheMode::Memory && entry_pool_) {
|
||||
return entry_pool_->GetEntryCount();
|
||||
}
|
||||
if (config_.mode == CacheMode::Disk) {
|
||||
std::shared_lock lock(disk_files_mutex_);
|
||||
size_t total = 0;
|
||||
for (const auto& [_, file] : disk_files_) {
|
||||
if (file) {
|
||||
total += file->GetUsedCount();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool
|
||||
@@ -64,21 +283,44 @@ ExprResCacheManager::Get(const Key& key, Value& out_value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto it = concurrent_map_.find(key);
|
||||
if (it == concurrent_map_.end()) {
|
||||
std::shared_lock state_lock(state_mutex_);
|
||||
if (!IsEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out_value = it->second.value;
|
||||
{
|
||||
std::lock_guard<std::mutex> lru_lock(lru_mutex_);
|
||||
lru_list_.splice(lru_list_.begin(), lru_list_, it->second.lru_it);
|
||||
if (config_.mode == CacheMode::Memory) {
|
||||
if (!entry_pool_) {
|
||||
return false;
|
||||
}
|
||||
TargetBitmap result(0), valid(0);
|
||||
if (!entry_pool_->Get(key.segment_id,
|
||||
key.signature,
|
||||
out_value.active_count,
|
||||
result,
|
||||
valid)) {
|
||||
return false;
|
||||
}
|
||||
out_value.result = std::make_shared<TargetBitmap>(std::move(result));
|
||||
out_value.valid_result =
|
||||
std::make_shared<TargetBitmap>(std::move(valid));
|
||||
return true;
|
||||
} else {
|
||||
// Disk mode
|
||||
std::shared_lock lock(disk_files_mutex_);
|
||||
auto it = disk_files_.find(key.segment_id);
|
||||
if (it == disk_files_.end()) {
|
||||
return false;
|
||||
}
|
||||
TargetBitmap result(0), valid(0);
|
||||
if (!it->second->Get(
|
||||
key.signature, out_value.active_count, result, valid)) {
|
||||
return false;
|
||||
}
|
||||
out_value.result = std::make_shared<TargetBitmap>(std::move(result));
|
||||
out_value.valid_result =
|
||||
std::make_shared<TargetBitmap>(std::move(valid));
|
||||
TryTouchDiskSegment(key.segment_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
LOG_DEBUG("get expr res cache, segment_id: {}, key: {}",
|
||||
key.segment_id,
|
||||
key.signature);
|
||||
return true;
|
||||
}
|
||||
|
||||
void
|
||||
@@ -86,108 +328,351 @@ ExprResCacheManager::Put(const Key& key, const Value& value) {
|
||||
if (!IsEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!value.result || !value.valid_result) {
|
||||
return;
|
||||
}
|
||||
|
||||
size_t estimated_bytes = EstimateBytes(value);
|
||||
auto stored_value = value;
|
||||
stored_value.bytes = estimated_bytes;
|
||||
|
||||
auto it = concurrent_map_.find(key);
|
||||
if (it != concurrent_map_.end()) {
|
||||
auto old_bytes = it->second.value.bytes;
|
||||
it->second.value = stored_value;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lru_lock(lru_mutex_);
|
||||
lru_list_.splice(lru_list_.begin(), lru_list_, it->second.lru_it);
|
||||
current_bytes_.fetch_add(estimated_bytes - old_bytes);
|
||||
std::shared_lock state_lock(state_mutex_);
|
||||
if (!IsEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (config_.mode == CacheMode::Memory) {
|
||||
if (!entry_pool_) {
|
||||
return;
|
||||
}
|
||||
const bool same_signature_cached =
|
||||
entry_pool_->HasSignature(key.segment_id, key.signature);
|
||||
if (!same_signature_cached && config_.mem_min_eval_duration_us > 0 &&
|
||||
value.eval_duration_us > 0 &&
|
||||
value.eval_duration_us < config_.mem_min_eval_duration_us) {
|
||||
return;
|
||||
}
|
||||
if (!same_signature_cached &&
|
||||
!frequency_tracker_.RecordAndCheck(
|
||||
XXH64(key.signature.data(), key.signature.size(), 0),
|
||||
config_.admission_threshold)) {
|
||||
return;
|
||||
}
|
||||
entry_pool_->Put(key.segment_id,
|
||||
key.signature,
|
||||
value.active_count,
|
||||
*value.result,
|
||||
*value.valid_result,
|
||||
value.eval_duration_us);
|
||||
SyncUsageMetrics(entry_pool_->GetCurrentBytes(), 0);
|
||||
} else {
|
||||
ListIt list_it;
|
||||
{
|
||||
std::lock_guard<std::mutex> lru_lock(lru_mutex_);
|
||||
lru_list_.push_front(key);
|
||||
list_it = lru_list_.begin();
|
||||
current_bytes_.fetch_add(estimated_bytes);
|
||||
// Disk mode
|
||||
if (config_.disk_base_path.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Entry entry(stored_value, list_it);
|
||||
concurrent_map_.emplace(key, std::move(entry));
|
||||
}
|
||||
bool replacing_existing = false;
|
||||
{
|
||||
std::shared_lock lock(disk_files_mutex_);
|
||||
if (disk_ineligible_segments_.find(key.segment_id) !=
|
||||
disk_ineligible_segments_.end()) {
|
||||
return;
|
||||
}
|
||||
auto it = disk_files_.find(key.segment_id);
|
||||
if (it != disk_files_.end()) {
|
||||
replacing_existing = it->second->HasSignature(key.signature);
|
||||
}
|
||||
}
|
||||
|
||||
if (current_bytes_.load() > capacity_bytes_.load()) {
|
||||
EnsureCapacity();
|
||||
}
|
||||
// Latency admission (disk mode)
|
||||
if (!replacing_existing && config_.disk_min_eval_duration_us > 0 &&
|
||||
value.eval_duration_us > 0 &&
|
||||
value.eval_duration_us < config_.disk_min_eval_duration_us) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DEBUG("put expr res cache, segment_id: {}, key: {}",
|
||||
key.segment_id,
|
||||
key.signature);
|
||||
// Frequency admission is mode-independent. Applying it before opening
|
||||
// the segment file avoids one-off expressions consuming disk slots and
|
||||
// issuing unnecessary pwrite calls.
|
||||
if (!replacing_existing &&
|
||||
!frequency_tracker_.RecordAndCheck(
|
||||
XXH64(key.signature.data(), key.signature.size(), 0),
|
||||
config_.admission_threshold)) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::unique_lock lock(disk_files_mutex_);
|
||||
if (disk_ineligible_segments_.find(key.segment_id) !=
|
||||
disk_ineligible_segments_.end()) {
|
||||
return;
|
||||
}
|
||||
std::string path = config_.disk_base_path + "/seg_" +
|
||||
std::to_string(key.segment_id) + ".cache";
|
||||
auto& file = disk_files_[key.segment_id];
|
||||
if (file &&
|
||||
file->GetRowCount() != static_cast<int64_t>(value.result->size())) {
|
||||
// Disk cache is sealed-only. A row-count change identifies a
|
||||
// growing/unstable segment for this backend, so drop the old fixed
|
||||
// file and skip future disk puts until the segment/config resets.
|
||||
RemoveDiskSegmentFile(key.segment_id);
|
||||
disk_ineligible_segments_.insert(key.segment_id);
|
||||
SyncUsageMetrics(0, GetDiskCurrentBytesLocked());
|
||||
return;
|
||||
}
|
||||
if (!file) {
|
||||
file = std::make_unique<DiskSlotFile>(
|
||||
key.segment_id,
|
||||
path,
|
||||
static_cast<int64_t>(value.result->size()),
|
||||
config_.disk_max_file_size);
|
||||
}
|
||||
file->Put(key.signature,
|
||||
value.active_count,
|
||||
*value.result,
|
||||
*value.valid_result);
|
||||
TouchDiskSegment(key.segment_id);
|
||||
EvictDiskSegmentsUntilWithinBudget(key.segment_id);
|
||||
SyncUsageMetrics(0, GetDiskCurrentBytesLocked());
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::Clear() {
|
||||
std::lock_guard<std::mutex> lru_lock(lru_mutex_);
|
||||
|
||||
concurrent_map_.clear();
|
||||
lru_list_.clear();
|
||||
current_bytes_.store(0);
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::EstimateBytes(const Value& v) const {
|
||||
size_t bytes = sizeof(Value);
|
||||
if (v.result) {
|
||||
bytes += (v.result->size() + 7) / 8;
|
||||
std::unique_lock state_lock(state_mutex_);
|
||||
if (entry_pool_) {
|
||||
entry_pool_->Clear();
|
||||
}
|
||||
if (v.valid_result) {
|
||||
bytes += (v.valid_result->size() + 7) / 8;
|
||||
frequency_tracker_.Reset();
|
||||
{
|
||||
std::unique_lock lock(disk_files_mutex_);
|
||||
disk_files_.clear();
|
||||
disk_ineligible_segments_.clear();
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::EnsureCapacity() {
|
||||
std::lock_guard<std::mutex> lru_lock(lru_mutex_);
|
||||
while (current_bytes_.load() > capacity_bytes_.load() &&
|
||||
!lru_list_.empty()) {
|
||||
const auto& back_key = lru_list_.back();
|
||||
auto it = concurrent_map_.find(back_key);
|
||||
if (it != concurrent_map_.end()) {
|
||||
current_bytes_.fetch_sub(it->second.value.bytes);
|
||||
concurrent_map_.unsafe_erase(it);
|
||||
}
|
||||
lru_list_.pop_back();
|
||||
{
|
||||
std::lock_guard lock(disk_clock_mutex_);
|
||||
disk_clock_segments_.clear();
|
||||
disk_clock_index_.clear();
|
||||
disk_segment_usage_.clear();
|
||||
disk_clock_hand_ = 0;
|
||||
}
|
||||
if (!config_.disk_base_path.empty()) {
|
||||
RemoveCacheFilesInDir(config_.disk_base_path);
|
||||
}
|
||||
SyncUsageMetrics(0, 0);
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::EraseSegment(int64_t segment_id) {
|
||||
size_t erased = 0;
|
||||
std::lock_guard<std::mutex> lru_lock(lru_mutex_);
|
||||
for (auto it = lru_list_.begin(); it != lru_list_.end();) {
|
||||
if (it->segment_id == segment_id) {
|
||||
auto map_it = concurrent_map_.find(*it);
|
||||
if (map_it != concurrent_map_.end()) {
|
||||
current_bytes_.fetch_sub(map_it->second.value.bytes);
|
||||
concurrent_map_.unsafe_erase(map_it);
|
||||
}
|
||||
it = lru_list_.erase(it);
|
||||
++erased;
|
||||
} else {
|
||||
++it;
|
||||
std::unique_lock state_lock(state_mutex_);
|
||||
if (config_.mode == CacheMode::Memory) {
|
||||
size_t erased = entry_pool_ ? entry_pool_->EraseSegment(segment_id) : 0;
|
||||
SyncUsageMetrics(entry_pool_ ? entry_pool_->GetCurrentBytes() : 0, 0);
|
||||
return erased;
|
||||
} else {
|
||||
std::unique_lock lock(disk_files_mutex_);
|
||||
if (disk_files_.find(segment_id) == disk_files_.end()) {
|
||||
disk_ineligible_segments_.erase(segment_id);
|
||||
RemoveDiskClockSegment(segment_id);
|
||||
return 0;
|
||||
}
|
||||
RemoveDiskSegmentFile(segment_id);
|
||||
SyncUsageMetrics(0, GetDiskCurrentBytesLocked());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::GetDiskCurrentBytesLocked() const {
|
||||
size_t total = 0;
|
||||
for (const auto& [_, file] : disk_files_) {
|
||||
if (file) {
|
||||
total += file->GetUsedBytes();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::EvictDiskSegmentsUntilWithinBudget(
|
||||
int64_t protected_segment_id) {
|
||||
if (config_.disk_max_bytes == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t total = GetDiskCurrentBytesLocked();
|
||||
size_t erased = 0;
|
||||
while (total > config_.disk_max_bytes) {
|
||||
int64_t victim_segment_id = 0;
|
||||
bool found_victim = false;
|
||||
{
|
||||
std::lock_guard lock(disk_clock_mutex_);
|
||||
if (disk_clock_segments_.empty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
bool has_evictable_segment = false;
|
||||
for (auto segment_id : disk_clock_segments_) {
|
||||
if (segment_id != protected_segment_id) {
|
||||
has_evictable_segment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!has_evictable_segment) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (disk_clock_hand_ >= disk_clock_segments_.size()) {
|
||||
disk_clock_hand_ = 0;
|
||||
}
|
||||
|
||||
const auto segment_id = disk_clock_segments_[disk_clock_hand_];
|
||||
if (segment_id == protected_segment_id) {
|
||||
disk_clock_hand_ =
|
||||
(disk_clock_hand_ + 1) % disk_clock_segments_.size();
|
||||
continue;
|
||||
}
|
||||
|
||||
auto usage_it = disk_segment_usage_.find(segment_id);
|
||||
if (usage_it != disk_segment_usage_.end() && usage_it->second > 0) {
|
||||
--usage_it->second;
|
||||
disk_clock_hand_ =
|
||||
(disk_clock_hand_ + 1) % disk_clock_segments_.size();
|
||||
continue;
|
||||
}
|
||||
|
||||
victim_segment_id = segment_id;
|
||||
found_victim = true;
|
||||
}
|
||||
|
||||
if (!found_victim) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t file_size = RemoveDiskSegmentFile(victim_segment_id);
|
||||
total = file_size > total ? 0 : total - file_size;
|
||||
++erased;
|
||||
}
|
||||
|
||||
if (total > config_.disk_max_bytes) {
|
||||
LOG_WARN(
|
||||
"ExprResCacheManager: disk cache usage {} exceeds budget {}, "
|
||||
"protected segment {} is kept",
|
||||
total,
|
||||
config_.disk_max_bytes,
|
||||
protected_segment_id);
|
||||
}
|
||||
|
||||
LOG_INFO("erase segment cache, segment_id: {}, erased: {} entries",
|
||||
segment_id,
|
||||
erased);
|
||||
return erased;
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::Init(size_t capacity_bytes, bool enabled) {
|
||||
SetEnabled(enabled);
|
||||
Instance().SetCapacityBytes(capacity_bytes);
|
||||
ExprResCacheManager::TouchDiskSegment(int64_t segment_id) {
|
||||
static constexpr uint8_t kMaxSegmentUsage = 5;
|
||||
|
||||
std::lock_guard lock(disk_clock_mutex_);
|
||||
if (disk_clock_index_.find(segment_id) == disk_clock_index_.end()) {
|
||||
disk_clock_index_[segment_id] = disk_clock_segments_.size();
|
||||
disk_clock_segments_.push_back(segment_id);
|
||||
disk_segment_usage_[segment_id] = 0;
|
||||
}
|
||||
|
||||
auto& usage = disk_segment_usage_[segment_id];
|
||||
if (usage < kMaxSegmentUsage) {
|
||||
++usage;
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::TryTouchDiskSegment(int64_t segment_id) {
|
||||
static constexpr uint8_t kMaxSegmentUsage = 5;
|
||||
|
||||
std::unique_lock lock(disk_clock_mutex_, std::try_to_lock);
|
||||
if (!lock.owns_lock()) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto usage_it = disk_segment_usage_.find(segment_id);
|
||||
if (usage_it == disk_segment_usage_.end()) {
|
||||
return;
|
||||
}
|
||||
if (usage_it->second < kMaxSegmentUsage) {
|
||||
++usage_it->second;
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::RemoveDiskClockSegment(int64_t segment_id) {
|
||||
std::lock_guard lock(disk_clock_mutex_);
|
||||
auto index_it = disk_clock_index_.find(segment_id);
|
||||
if (index_it == disk_clock_index_.end()) {
|
||||
disk_segment_usage_.erase(segment_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t index = index_it->second;
|
||||
const size_t last_index = disk_clock_segments_.size() - 1;
|
||||
if (index != last_index) {
|
||||
const auto moved_segment_id = disk_clock_segments_[last_index];
|
||||
disk_clock_segments_[index] = moved_segment_id;
|
||||
disk_clock_index_[moved_segment_id] = index;
|
||||
}
|
||||
|
||||
disk_clock_segments_.pop_back();
|
||||
disk_clock_index_.erase(segment_id);
|
||||
disk_segment_usage_.erase(segment_id);
|
||||
|
||||
if (disk_clock_segments_.empty()) {
|
||||
disk_clock_hand_ = 0;
|
||||
} else if (index < disk_clock_hand_) {
|
||||
--disk_clock_hand_;
|
||||
} else if (disk_clock_hand_ >= disk_clock_segments_.size()) {
|
||||
disk_clock_hand_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
size_t
|
||||
ExprResCacheManager::RemoveDiskSegmentFile(int64_t segment_id) {
|
||||
auto it = disk_files_.find(segment_id);
|
||||
if (it == disk_files_.end()) {
|
||||
RemoveDiskClockSegment(segment_id);
|
||||
disk_ineligible_segments_.erase(segment_id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto* file = it->second.get();
|
||||
const size_t file_size = file ? file->GetUsedBytes() : 0;
|
||||
if (file) {
|
||||
file->Close();
|
||||
}
|
||||
|
||||
std::string path = config_.disk_base_path + "/seg_" +
|
||||
std::to_string(segment_id) + ".cache";
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(path, ec);
|
||||
if (ec) {
|
||||
LOG_WARN("ExprResCacheManager: failed to remove cache file {}: {}",
|
||||
path,
|
||||
ec.message());
|
||||
}
|
||||
|
||||
disk_files_.erase(it);
|
||||
disk_ineligible_segments_.erase(segment_id);
|
||||
RemoveDiskClockSegment(segment_id);
|
||||
return file_size;
|
||||
}
|
||||
|
||||
void
|
||||
ExprResCacheManager::SyncUsageMetrics(size_t memory_bytes, size_t disk_bytes) {
|
||||
auto old_memory = reported_memory_bytes_.exchange(
|
||||
memory_bytes, std::memory_order_relaxed);
|
||||
auto old_disk =
|
||||
reported_disk_bytes_.exchange(disk_bytes, std::memory_order_relaxed);
|
||||
|
||||
UpdateGauge(
|
||||
cachinglayer::monitor::cache_loaded_bytes(
|
||||
cachinglayer::CellDataType::OTHER,
|
||||
cachinglayer::StorageType::MEMORY),
|
||||
static_cast<int64_t>(memory_bytes) - static_cast<int64_t>(old_memory));
|
||||
UpdateGauge(
|
||||
cachinglayer::monitor::cache_loaded_bytes(
|
||||
cachinglayer::CellDataType::OTHER, cachinglayer::StorageType::DISK),
|
||||
static_cast<int64_t>(disk_bytes) - static_cast<int64_t>(old_disk));
|
||||
}
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
} // namespace milvus
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <atomic>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <tbb/concurrent_unordered_map.h>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "common/Types.h"
|
||||
#include "log/Log.h"
|
||||
@@ -33,7 +33,82 @@
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
// Process-level LRU cache for expression result bitsets.
|
||||
// Forward declarations for backend types
|
||||
class EntryPool;
|
||||
class DiskSlotFile;
|
||||
|
||||
// Lightweight frequency tracker using direct-mapped counter array.
|
||||
// Used for cache admission control: only cache expressions seen >= threshold times.
|
||||
// Approximate — hash collisions cause shared counters, which is acceptable.
|
||||
class FrequencyTracker {
|
||||
public:
|
||||
// Record a signature hash and return whether it has reached the threshold.
|
||||
bool
|
||||
RecordAndCheck(uint64_t sig_hash, uint8_t threshold) {
|
||||
if (threshold <= 1) {
|
||||
return true; // no admission control
|
||||
}
|
||||
size_t slot = sig_hash % kNumSlots;
|
||||
uint8_t old = counters_[slot].fetch_add(1, std::memory_order_relaxed);
|
||||
// Cap at 255 to prevent overflow
|
||||
if (old >= 254) {
|
||||
counters_[slot].store(254, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// Periodic decay: every kDecayInterval records, halve all counters.
|
||||
uint64_t count = total_records_.fetch_add(1, std::memory_order_relaxed);
|
||||
if ((count + 1) % kDecayInterval == 0) {
|
||||
Decay();
|
||||
}
|
||||
|
||||
return (old + 1) >= threshold;
|
||||
}
|
||||
|
||||
void
|
||||
Reset() {
|
||||
for (auto& c : counters_) {
|
||||
c.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
total_records_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
private:
|
||||
void
|
||||
Decay() {
|
||||
for (auto& c : counters_) {
|
||||
uint8_t val = c.load(std::memory_order_relaxed);
|
||||
c.store(val / 2, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
static constexpr size_t kNumSlots = 16384; // 16K slots = 16KB memory
|
||||
static constexpr uint64_t kDecayInterval =
|
||||
10000; // decay every 10K records
|
||||
|
||||
std::atomic<uint8_t> counters_[kNumSlots]{};
|
||||
std::atomic<uint64_t> total_records_{0};
|
||||
};
|
||||
|
||||
// Cache mode: Memory (EntryPool) or Disk (DiskSlotFile).
|
||||
enum class CacheMode { Memory, Disk };
|
||||
|
||||
// Configuration for ExprResCacheManager.
|
||||
struct CacheConfig {
|
||||
CacheMode mode{CacheMode::Disk};
|
||||
uint8_t admission_threshold{2};
|
||||
// Memory mode
|
||||
size_t mem_max_bytes{256ULL * 1024 * 1024};
|
||||
bool compression_enabled{true};
|
||||
int64_t mem_min_eval_duration_us{1000};
|
||||
// Disk mode
|
||||
std::string disk_base_path;
|
||||
uint64_t disk_max_bytes{10ULL * 1024 * 1024 * 1024};
|
||||
uint64_t disk_max_file_size{256ULL * 1024 * 1024};
|
||||
int64_t disk_min_eval_duration_us{1000};
|
||||
};
|
||||
|
||||
// Process-level expression result cache with mode dispatch.
|
||||
// Routes to EntryPool (memory mode) or per-segment DiskSlotFile (disk mode).
|
||||
class ExprResCacheManager {
|
||||
public:
|
||||
struct Key {
|
||||
@@ -61,6 +136,8 @@ class ExprResCacheManager {
|
||||
std::shared_ptr<TargetBitmap> valid_result; // valid bits
|
||||
int64_t active_count{0}; // active count when cached
|
||||
size_t bytes{0}; // approximate size in bytes
|
||||
int64_t eval_duration_us{
|
||||
0}; // eval duration in us, 0 = skip cost check
|
||||
};
|
||||
|
||||
public:
|
||||
@@ -73,6 +150,23 @@ class ExprResCacheManager {
|
||||
static void
|
||||
Init(size_t capacity_bytes, bool enabled);
|
||||
|
||||
// Configure the cache mode and parameters.
|
||||
bool
|
||||
SetConfig(const CacheConfig& config);
|
||||
|
||||
CacheMode
|
||||
GetMode() const;
|
||||
|
||||
// Backward-compatible shim: maps old SetDiskConfig parameters to CacheConfig.
|
||||
void
|
||||
SetDiskConfig(const std::string& base_path,
|
||||
uint64_t max_total_size,
|
||||
uint64_t max_segment_file_size,
|
||||
bool compression_enabled,
|
||||
uint8_t admission_threshold = 1,
|
||||
int64_t min_eval_duration_us = 0,
|
||||
bool in_memory = false);
|
||||
|
||||
void
|
||||
SetCapacityBytes(size_t capacity_bytes);
|
||||
size_t
|
||||
@@ -83,6 +177,7 @@ class ExprResCacheManager {
|
||||
GetEntryCount() const;
|
||||
|
||||
// Try to get cached value. If found, returns true and fills out_value.
|
||||
// NOTE: caller must pre-set out_value.active_count for staleness check.
|
||||
bool
|
||||
Get(const Key& key, Value& out_value);
|
||||
|
||||
@@ -101,29 +196,47 @@ class ExprResCacheManager {
|
||||
ExprResCacheManager() = default;
|
||||
|
||||
size_t
|
||||
EstimateBytes(const Value& v) const;
|
||||
GetDiskCurrentBytesLocked() const;
|
||||
|
||||
size_t
|
||||
EvictDiskSegmentsUntilWithinBudget(int64_t protected_segment_id);
|
||||
|
||||
void
|
||||
EnsureCapacity();
|
||||
TouchDiskSegment(int64_t segment_id);
|
||||
|
||||
void
|
||||
TryTouchDiskSegment(int64_t segment_id);
|
||||
|
||||
void
|
||||
RemoveDiskClockSegment(int64_t segment_id);
|
||||
|
||||
size_t
|
||||
RemoveDiskSegmentFile(int64_t segment_id);
|
||||
|
||||
void
|
||||
SyncUsageMetrics(size_t memory_bytes, size_t disk_bytes);
|
||||
|
||||
private:
|
||||
static std::atomic<bool> enabled_;
|
||||
std::atomic<size_t> capacity_bytes_{256ull * 1024ull *
|
||||
1024ull}; // default 256MB
|
||||
std::atomic<size_t> current_bytes_{0};
|
||||
|
||||
mutable std::mutex lru_mutex_;
|
||||
std::list<Key> lru_list_;
|
||||
using ListIt = std::list<Key>::iterator;
|
||||
struct Entry {
|
||||
Value value;
|
||||
ListIt lru_it;
|
||||
mutable std::shared_mutex state_mutex_;
|
||||
CacheConfig config_;
|
||||
|
||||
Entry() = default;
|
||||
Entry(const Value& v, ListIt it) : value(v), lru_it(it) {
|
||||
}
|
||||
};
|
||||
// Memory mode backend
|
||||
std::unique_ptr<EntryPool> entry_pool_;
|
||||
|
||||
tbb::concurrent_unordered_map<Key, Entry, KeyHasher> concurrent_map_;
|
||||
// Disk mode backend
|
||||
mutable std::shared_mutex disk_files_mutex_;
|
||||
std::unordered_map<int64_t, std::unique_ptr<DiskSlotFile>> disk_files_;
|
||||
std::unordered_set<int64_t> disk_ineligible_segments_;
|
||||
mutable std::mutex disk_clock_mutex_;
|
||||
std::vector<int64_t> disk_clock_segments_;
|
||||
std::unordered_map<int64_t, size_t> disk_clock_index_;
|
||||
std::unordered_map<int64_t, uint8_t> disk_segment_usage_;
|
||||
size_t disk_clock_hand_{0};
|
||||
|
||||
FrequencyTracker frequency_tracker_;
|
||||
std::atomic<size_t> reported_memory_bytes_{0};
|
||||
std::atomic<size_t> reported_disk_bytes_{0};
|
||||
};
|
||||
|
||||
// Helper API: erase all cache for a given segment id, returns erased entry count
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// Licensed to the LF AI & Data foundation under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "common/Types.h"
|
||||
#include "exec/expression/ExprCache.h"
|
||||
#include "segcore/SegmentInterface.h"
|
||||
|
||||
namespace milvus {
|
||||
namespace exec {
|
||||
|
||||
class BatchedCachedMixin;
|
||||
|
||||
// Wrapper around ExprResCacheManager to simplify the get→compute→put pattern
|
||||
// for expression implementations. Each expression only needs to:
|
||||
// 1. Provide a stable ToString() signature
|
||||
// 2. Wrap its compute logic in a lambda
|
||||
//
|
||||
// The helper handles: cache lookup, miss path timing, put, and the
|
||||
// sealed-segment / enabled checks.
|
||||
//
|
||||
// Example usage in an expression:
|
||||
//
|
||||
// auto cached = exec::ExprCacheHelper::GetOrCompute(
|
||||
// segment_, this->ToString(), active_count_,
|
||||
// [&]() -> exec::ExprCacheHelper::ComputeResult {
|
||||
// TargetBitmap res = do_actual_computation();
|
||||
// TargetBitmap valid = compute_valid();
|
||||
// return {std::move(res), std::move(valid)};
|
||||
// });
|
||||
// result_bitmap = cached.result;
|
||||
// valid_bitmap = cached.valid;
|
||||
|
||||
class ExprCacheHelper {
|
||||
public:
|
||||
struct CachedBitmaps {
|
||||
std::shared_ptr<TargetBitmap> result;
|
||||
std::shared_ptr<TargetBitmap> valid;
|
||||
};
|
||||
|
||||
// Return type of the compute lambda: (result_bitmap, valid_bitmap).
|
||||
// Valid bitmap may be all-ones for expressions that don't produce
|
||||
// nullability (e.g. unary comparisons on non-nullable fields).
|
||||
struct ComputeResult {
|
||||
TargetBitmap result;
|
||||
TargetBitmap valid;
|
||||
};
|
||||
|
||||
// Try cache; on miss, call `compute`, put result into cache, return.
|
||||
// Backend semantics:
|
||||
// - Memory mode supports sealed and growing segments. `active_count`
|
||||
// participates in the memory cache key, so growing-segment snapshots
|
||||
// with different row counts are isolated from each other.
|
||||
// - Disk mode is sealed-segment only. DiskSlotFile uses fixed-size slots
|
||||
// derived from row_count; if a segment's row_count changes, the manager
|
||||
// drops that disk file and skips disk caching for the segment.
|
||||
//
|
||||
// Skips the cache entirely if ExprResCacheManager is disabled. Disk mode
|
||||
// also skips growing segments because DiskSlotFile has fixed row_count slots.
|
||||
//
|
||||
// Correctness requirements for the caller:
|
||||
// - `expr_signature` MUST uniquely identify the expression and its
|
||||
// parameters. Same parameters → same signature. Field order in the
|
||||
// string must be fixed (don't rely on protobuf DebugString).
|
||||
// - `active_count` MUST be the current segment row count. Used to
|
||||
// detect staleness after insert/compaction.
|
||||
// - `compute` MUST be deterministic: same segment + same signature
|
||||
// + same active_count must always produce the same bitmaps.
|
||||
template <typename ComputeFn>
|
||||
static CachedBitmaps
|
||||
GetOrCompute(const segcore::SegmentInternalInterface* segment,
|
||||
const std::string& expr_signature,
|
||||
int64_t active_count,
|
||||
ComputeFn&& compute,
|
||||
bool enable_cache_write = true) {
|
||||
bool cache_eligible =
|
||||
segment != nullptr && ExprResCacheManager::IsEnabled();
|
||||
if (cache_eligible &&
|
||||
ExprResCacheManager::Instance().GetMode() == CacheMode::Disk &&
|
||||
segment->type() != SegmentType::Sealed) {
|
||||
cache_eligible = false;
|
||||
}
|
||||
|
||||
if (cache_eligible) {
|
||||
// Try Get
|
||||
ExprResCacheManager::Key key{segment->get_segment_id(),
|
||||
expr_signature};
|
||||
ExprResCacheManager::Value got;
|
||||
got.active_count = active_count;
|
||||
if (ExprResCacheManager::Instance().Get(key, got)) {
|
||||
return {got.result, got.valid_result};
|
||||
}
|
||||
}
|
||||
|
||||
const bool cache_can_write = cache_eligible && enable_cache_write;
|
||||
if (!cache_can_write) {
|
||||
ComputeResult out = compute();
|
||||
auto result = std::make_shared<TargetBitmap>(std::move(out.result));
|
||||
auto valid = std::make_shared<TargetBitmap>(std::move(out.valid));
|
||||
return {result, valid};
|
||||
}
|
||||
|
||||
// Miss: run compute with timing for latency admission.
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
ComputeResult out = compute();
|
||||
auto eval_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - t0)
|
||||
.count();
|
||||
|
||||
auto result = std::make_shared<TargetBitmap>(std::move(out.result));
|
||||
auto valid = std::make_shared<TargetBitmap>(std::move(out.valid));
|
||||
|
||||
ExprResCacheManager::Key key{segment->get_segment_id(), expr_signature};
|
||||
ExprResCacheManager::Value v;
|
||||
v.result = result;
|
||||
v.valid_result = valid;
|
||||
v.active_count = active_count;
|
||||
v.eval_duration_us = eval_us;
|
||||
ExprResCacheManager::Instance().Put(key, v);
|
||||
|
||||
return {result, valid};
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// BatchedCachedMixin — eliminate boilerplate for cached expressions
|
||||
// ============================================================
|
||||
//
|
||||
// Many filter expressions use the Volcano execution model: each Eval()
|
||||
// returns one batch_size slice. When caching, we want:
|
||||
// 1. First Eval: compute the FULL segment bitset (or load from cache),
|
||||
// store it in a member, then slice the current batch.
|
||||
// 2. Later Evals: just slice from the stored full bitset.
|
||||
//
|
||||
// This mixin encapsulates that pattern. A derived expression inherits
|
||||
// it and implements `ComputeFullBitset()`. Call sites only need:
|
||||
//
|
||||
// auto [result, valid, advance] = EnsureFullAndSlice(batch_size);
|
||||
//
|
||||
// The mixin handles:
|
||||
// - Cache lookup on first access
|
||||
// - Compute + timing + cache put on miss
|
||||
// - Slicing the current batch from the full bitset
|
||||
// - Cursor management (via `batch_offset_`)
|
||||
//
|
||||
// Expressions holding this mixin must also provide:
|
||||
// - A valid `segment_` pointer
|
||||
// - `active_count_` (current row count)
|
||||
// - `ToString()` returning a stable signature
|
||||
// - `ComputeFullBitset()` implementation in the derived class
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// class PhyJsonContainsExpr : public SegmentExpr,
|
||||
// public BatchedCachedMixin {
|
||||
// VectorPtr Eval() override {
|
||||
// auto [result, valid, has_more] =
|
||||
// this->NextBatchViaCache(GetNextBatchSize(), this);
|
||||
// if (!has_more) return nullptr;
|
||||
// return std::make_shared<ColumnVector>(std::move(result),
|
||||
// std::move(valid));
|
||||
// }
|
||||
//
|
||||
// ExprCacheHelper::ComputeResult ComputeFullBitset() {
|
||||
// TargetBitmap r(active_count_);
|
||||
// TargetBitmap v(active_count_, true);
|
||||
// for (int64_t i = 0; i < active_count_; ++i) {
|
||||
// r[i] = DoJsonContains(i);
|
||||
// }
|
||||
// return {std::move(r), std::move(v)};
|
||||
// }
|
||||
// };
|
||||
|
||||
class BatchedCachedMixin {
|
||||
public:
|
||||
struct BatchSlice {
|
||||
TargetBitmap result; // size = actual_batch_size
|
||||
TargetBitmap valid; // size = actual_batch_size
|
||||
bool has_data{false}; // false when cursor is past the end
|
||||
};
|
||||
|
||||
// Reset cache state between queries (call from the expression's reset/init)
|
||||
void
|
||||
ResetCache() {
|
||||
full_result_.reset();
|
||||
full_valid_.reset();
|
||||
batch_offset_ = 0;
|
||||
}
|
||||
|
||||
// Derived expression calls this from Eval() to get the next batch slice.
|
||||
// On first call, runs cache lookup / compute via ExprCacheHelper.
|
||||
// On subsequent calls, slices from the stored full bitset.
|
||||
//
|
||||
// `derived` should be `this` (the derived expression), used to access
|
||||
// segment/signature/active_count/ComputeFullBitset via CRTP-like dispatch.
|
||||
template <typename Derived>
|
||||
BatchSlice
|
||||
NextBatchViaCache(int64_t batch_size, Derived* derived) {
|
||||
EnsureFullLoaded(derived);
|
||||
|
||||
BatchSlice out;
|
||||
const int64_t total = static_cast<int64_t>(full_result_->size());
|
||||
if (batch_offset_ >= total || batch_size <= 0) {
|
||||
return out; // has_data=false → end of stream
|
||||
}
|
||||
const int64_t actual =
|
||||
std::min<int64_t>(batch_size, total - batch_offset_);
|
||||
out.result = TargetBitmap(actual);
|
||||
out.valid = TargetBitmap(actual);
|
||||
out.result.append(*full_result_, batch_offset_, actual);
|
||||
out.valid.append(*full_valid_, batch_offset_, actual);
|
||||
out.has_data = true;
|
||||
batch_offset_ += actual;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Take exclusive ownership of the full bitsets (use_count == 1 after this
|
||||
// call, enabling std::move of the underlying TargetBitmap by the caller).
|
||||
// After this call the mixin's internal state is cleared; subsequent
|
||||
// NextBatchViaCache / TakeFullBitset calls will reload from cache/recompute.
|
||||
//
|
||||
// Use this in `execute_all_at_once_` optimization paths where you want to
|
||||
// avoid the batch-slice append copy:
|
||||
//
|
||||
// if (execute_all_at_once_) {
|
||||
// auto full = TakeFullBitset(this);
|
||||
// MoveCursor();
|
||||
// return std::make_shared<ColumnVector>(
|
||||
// std::move(*full.result), std::move(*full.valid));
|
||||
// }
|
||||
// // else: normal batch loop
|
||||
// auto slice = NextBatchViaCache(batch_size, this);
|
||||
// ...
|
||||
template <typename Derived>
|
||||
ExprCacheHelper::CachedBitmaps
|
||||
TakeFullBitset(Derived* derived) {
|
||||
EnsureFullLoaded(derived);
|
||||
ExprCacheHelper::CachedBitmaps out{std::move(full_result_),
|
||||
std::move(full_valid_)};
|
||||
// Reset so a subsequent call reloads (mixin no longer owns the data).
|
||||
// Note: the underlying cache entry is still on disk/memory, so reload
|
||||
// is cheap (single Get).
|
||||
batch_offset_ = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
// True if the full bitset has been loaded into the mixin already.
|
||||
bool
|
||||
IsFullLoaded() const {
|
||||
return full_result_ != nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Derived>
|
||||
void
|
||||
EnsureFullLoaded(Derived* derived) {
|
||||
if (full_result_ != nullptr) {
|
||||
return;
|
||||
}
|
||||
auto cached = ExprCacheHelper::GetOrCompute(
|
||||
derived->cache_segment(),
|
||||
derived->cache_signature(),
|
||||
derived->cache_active_count(),
|
||||
[derived]() { return derived->ComputeFullBitset(); });
|
||||
full_result_ = cached.result;
|
||||
full_valid_ = cached.valid;
|
||||
}
|
||||
|
||||
public:
|
||||
// Advance cursor without computing (used for cache-skip optimizations)
|
||||
void
|
||||
AdvanceBatchCursor(int64_t step) {
|
||||
batch_offset_ += step;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::shared_ptr<TargetBitmap> full_result_;
|
||||
std::shared_ptr<TargetBitmap> full_valid_;
|
||||
int64_t batch_offset_{0};
|
||||
};
|
||||
|
||||
} // namespace exec
|
||||
} // namespace milvus
|
||||
File diff suppressed because it is too large
Load Diff
@@ -588,8 +588,11 @@ PhyJsonContainsFilterExpr::ExecJsonContainsByStats() {
|
||||
TargetBitmap(real_batch_size, true));
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto* segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -661,6 +664,7 @@ PhyJsonContainsFilterExpr::ExecJsonContainsByStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
@@ -816,8 +820,11 @@ PhyJsonContainsFilterExpr::ExecJsonContainsArrayByStats() {
|
||||
TargetBitmap(real_batch_size, true));
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto* segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -880,6 +887,7 @@ PhyJsonContainsFilterExpr::ExecJsonContainsArrayByStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
@@ -1209,8 +1217,11 @@ PhyJsonContainsFilterExpr::ExecJsonContainsAllByStats() {
|
||||
TargetBitmap(real_batch_size, true));
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto* segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -1333,6 +1344,7 @@ PhyJsonContainsFilterExpr::ExecJsonContainsAllByStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
@@ -1543,8 +1555,11 @@ PhyJsonContainsFilterExpr::ExecJsonContainsAllWithDiffTypeByStats() {
|
||||
TargetBitmap(real_batch_size, true));
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto* segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -1677,6 +1692,7 @@ PhyJsonContainsFilterExpr::ExecJsonContainsAllWithDiffTypeByStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
@@ -1832,8 +1848,11 @@ PhyJsonContainsFilterExpr::ExecJsonContainsAllArrayByStats() {
|
||||
TargetBitmap(real_batch_size, true));
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto* segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -1903,6 +1922,7 @@ PhyJsonContainsFilterExpr::ExecJsonContainsAllArrayByStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
@@ -2095,8 +2115,11 @@ PhyJsonContainsFilterExpr::ExecJsonContainsWithDiffTypeByStats() {
|
||||
TargetBitmap(real_batch_size, true));
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto* segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -2220,6 +2243,7 @@ PhyJsonContainsFilterExpr::ExecJsonContainsWithDiffTypeByStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
|
||||
@@ -612,8 +612,11 @@ PhyTermFilterExpr::ExecJsonInVariableByStats() {
|
||||
TargetBitmap(real_batch_size, true));
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto segment = dynamic_cast<const segcore::SegmentSealed*>(segment_);
|
||||
auto field_id = expr_->column_.field_id_;
|
||||
auto index = segment->GetJsonStats(op_ctx_, field_id);
|
||||
@@ -749,6 +752,7 @@ PhyTermFilterExpr::ExecJsonInVariableByStats() {
|
||||
op_ctx_, bson_index_, pointer, shared_executor);
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
#include <simdjson.h>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
@@ -40,6 +41,7 @@
|
||||
#include "common/Types.h"
|
||||
#include "common/type_c.h"
|
||||
#include "exec/expression/ExprCache.h"
|
||||
#include "exec/expression/ExprCacheHelper.h"
|
||||
#include "fmt/core.h"
|
||||
#include "folly/FBVector.h"
|
||||
#include "glog/logging.h"
|
||||
@@ -1062,8 +1064,11 @@ PhyUnaryRangeFilterExpr::ExecRangeVisitorImplJsonByStats() {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
if (cached_index_chunk_id_ != 0 && TryCacheGet()) {
|
||||
// Cache hit from a prior Index/Stats path — skip Stats computation.
|
||||
} else if (cached_index_chunk_id_ != 0 &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
auto cache_compute_start = CacheClock::now();
|
||||
auto pointerpath = milvus::Json::pointer(expr_->column_.nested_path_);
|
||||
auto pointerpair = SplitAtFirstSlashDigit(pointerpath);
|
||||
std::string pointer = pointerpair.first;
|
||||
@@ -1363,6 +1368,7 @@ PhyUnaryRangeFilterExpr::ExecRangeVisitorImplJsonByStats() {
|
||||
cached_index_chunk_res_->flip();
|
||||
}
|
||||
cached_index_chunk_id_ = 0;
|
||||
CachePut(CacheElapsedUs(cache_compute_start));
|
||||
}
|
||||
|
||||
auto res = MoveOrSliceBitmap(
|
||||
@@ -1985,24 +1991,6 @@ PhyUnaryRangeFilterExpr::ExecTextMatch() {
|
||||
}
|
||||
auto op_type = expr_->op_type_;
|
||||
|
||||
// Process-level LRU cache lookup by (segment_id, expr signature)
|
||||
if (cached_match_res_ == nullptr &&
|
||||
exec::ExprResCacheManager::IsEnabled() &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
exec::ExprResCacheManager::Key key{segment_->get_segment_id(),
|
||||
this->ToString()};
|
||||
exec::ExprResCacheManager::Value v;
|
||||
if (exec::ExprResCacheManager::Instance().Get(key, v)) {
|
||||
cached_match_res_ = v.result;
|
||||
cached_index_chunk_valid_res_ = v.valid_result;
|
||||
AssertInfo(cached_match_res_->size() == active_count_,
|
||||
"internal error: expr res cache size {} not equal "
|
||||
"expect active count {}",
|
||||
cached_match_res_->size(),
|
||||
active_count_);
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t min_should_match = 1; // default value
|
||||
if (op_type == proto::plan::OpType::TextMatch &&
|
||||
expr_->extra_values_.size() > 0) {
|
||||
@@ -2011,57 +1999,49 @@ PhyUnaryRangeFilterExpr::ExecTextMatch() {
|
||||
GetValueFromProto<int64_t>(expr_->extra_values_[0]));
|
||||
}
|
||||
|
||||
auto func = [op_type, slop, min_should_match](
|
||||
Index* index, const std::string& query) -> TargetBitmap {
|
||||
if (op_type == proto::plan::OpType::TextMatch) {
|
||||
return index->MatchQuery(query, min_should_match);
|
||||
} else if (op_type == proto::plan::OpType::PhraseMatch) {
|
||||
return index->PhraseMatchQuery(query, slop);
|
||||
} else {
|
||||
ThrowInfo(OpTypeInvalid,
|
||||
"unsupported operator type for match query: {}",
|
||||
op_type);
|
||||
}
|
||||
};
|
||||
|
||||
auto real_batch_size = GetNextBatchSize();
|
||||
if (real_batch_size == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Cache lookup + full-bitset compute via helper
|
||||
if (cached_match_res_ == nullptr) {
|
||||
auto pw = segment_->GetTextIndex(op_ctx_, field_id_);
|
||||
auto index = pw.get();
|
||||
auto res = func(index, query);
|
||||
auto valid_res = index->IsNotNull();
|
||||
cached_match_res_ = std::make_shared<TargetBitmap>(std::move(res));
|
||||
cached_index_chunk_valid_res_ =
|
||||
std::make_shared<TargetBitmap>(std::move(valid_res));
|
||||
if (cached_match_res_->size() < active_count_) {
|
||||
// some entities are not visible in inverted index.
|
||||
// only happend on growing segment.
|
||||
TargetBitmap tail(active_count_ - cached_match_res_->size());
|
||||
cached_match_res_->append(tail);
|
||||
cached_index_chunk_valid_res_->append(tail);
|
||||
} else if (cached_match_res_->size() > active_count_) {
|
||||
// on growing segments, the text index may have indexed rows
|
||||
// beyond the query timestamp. Truncate to active_count_.
|
||||
cached_match_res_->resize(active_count_);
|
||||
cached_index_chunk_valid_res_->resize(active_count_);
|
||||
}
|
||||
|
||||
// Insert into process-level cache
|
||||
if (enable_sub_expr_cache_write_ &&
|
||||
exec::ExprResCacheManager::IsEnabled() &&
|
||||
segment_->type() == SegmentType::Sealed) {
|
||||
exec::ExprResCacheManager::Key key{segment_->get_segment_id(),
|
||||
this->ToString()};
|
||||
exec::ExprResCacheManager::Value v;
|
||||
v.result = cached_match_res_;
|
||||
v.valid_result = cached_index_chunk_valid_res_;
|
||||
v.active_count = active_count_;
|
||||
exec::ExprResCacheManager::Instance().Put(key, v);
|
||||
}
|
||||
auto cached = exec::ExprCacheHelper::GetOrCompute(
|
||||
segment_,
|
||||
this->ToString(),
|
||||
active_count_,
|
||||
[&]() -> exec::ExprCacheHelper::ComputeResult {
|
||||
auto pw = segment_->GetTextIndex(op_ctx_, field_id_);
|
||||
auto index = pw.get();
|
||||
TargetBitmap res;
|
||||
if (op_type == proto::plan::OpType::TextMatch) {
|
||||
res = index->MatchQuery(query, min_should_match);
|
||||
} else if (op_type == proto::plan::OpType::PhraseMatch) {
|
||||
res = index->PhraseMatchQuery(query, slop);
|
||||
} else {
|
||||
ThrowInfo(OpTypeInvalid,
|
||||
"unsupported operator type for match query: {}",
|
||||
op_type);
|
||||
}
|
||||
auto valid_res = index->IsNotNull();
|
||||
if (res.size() < static_cast<size_t>(active_count_)) {
|
||||
// some entities are not visible in inverted index.
|
||||
// only happens on growing segment.
|
||||
TargetBitmap tail(active_count_ - res.size());
|
||||
res.append(tail);
|
||||
valid_res.append(tail);
|
||||
} else if (res.size() > static_cast<size_t>(active_count_)) {
|
||||
// on growing segments, the text index may have indexed
|
||||
// rows beyond the query timestamp. Truncate to
|
||||
// active_count_.
|
||||
res.resize(active_count_);
|
||||
valid_res.resize(active_count_);
|
||||
}
|
||||
return {std::move(res), std::move(valid_res)};
|
||||
},
|
||||
enable_sub_expr_cache_write_);
|
||||
cached_match_res_ = cached.result;
|
||||
cached_index_chunk_valid_res_ = cached.valid;
|
||||
}
|
||||
|
||||
// When execute_all_at_once_ and result is not shared with cache, move to avoid copy
|
||||
|
||||
@@ -118,6 +118,7 @@ PhyFilterBitsNode::GetOutput() {
|
||||
ExprResCacheManager::Key key{cache_segment->get_segment_id(),
|
||||
expr_cache_key_};
|
||||
ExprResCacheManager::Value cached;
|
||||
cached.active_count = need_process_rows_;
|
||||
if (ExprResCacheManager::Instance().Get(key, cached) &&
|
||||
cached.result != nullptr &&
|
||||
cached.result->size() == need_process_rows_) {
|
||||
|
||||
@@ -1762,11 +1762,13 @@ TEST(TextMatch, ExprResCacheFilterBitsDoesNotDuplicateTextMatchEntry) {
|
||||
ExprResCacheManager::Key filter_key{seg->get_segment_id(),
|
||||
expr->ToString()};
|
||||
ExprResCacheManager::Value filter_value;
|
||||
filter_value.active_count = N;
|
||||
ASSERT_TRUE(mgr.Get(filter_key, filter_value));
|
||||
|
||||
ExprResCacheManager::Key text_match_key{seg->get_segment_id(),
|
||||
expr->filter()->ToString()};
|
||||
ExprResCacheManager::Value text_match_value;
|
||||
text_match_value.active_count = N;
|
||||
ASSERT_FALSE(mgr.Get(text_match_key, text_match_value));
|
||||
|
||||
mgr.Clear();
|
||||
|
||||
@@ -672,17 +672,25 @@ func SetupCoreConfigChangelCallback() {
|
||||
return err
|
||||
}
|
||||
UpdateExprResCacheEnable(enable)
|
||||
if enable {
|
||||
UpdateExprResCacheConfig()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheCapacityBytes.RegisterCallback(func(ctx context.Context, key, oldValue, newValue string) error {
|
||||
capacity, err := strconv.Atoi(newValue)
|
||||
if err != nil {
|
||||
return err
|
||||
updateExprResCacheConfigCallback := func(ctx context.Context, key, oldValue, newValue string) error {
|
||||
if paramtable.Get().QueryNodeCfg.ExprResCacheEnabled.GetAsBool() {
|
||||
UpdateExprResCacheConfig()
|
||||
}
|
||||
UpdateExprResCacheCapacityBytes(capacity)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheMode.RegisterCallback(updateExprResCacheConfigCallback)
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheMinEvalDurationUs.RegisterCallback(updateExprResCacheConfigCallback)
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheAdmissionThreshold.RegisterCallback(updateExprResCacheConfigCallback)
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheMemMaxBytes.RegisterCallback(updateExprResCacheConfigCallback)
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheMemCompressionEnabled.RegisterCallback(updateExprResCacheConfigCallback)
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheDiskMaxBytes.RegisterCallback(updateExprResCacheConfigCallback)
|
||||
paramtable.Get().QueryNodeCfg.ExprResCacheDiskMaxFileSizeBytes.RegisterCallback(updateExprResCacheConfigCallback)
|
||||
|
||||
updateTieredStorageConfigCallback := func(ctx context.Context, key, oldValue, newValue string) error {
|
||||
return UpdateTieredStorageConfig(paramtable.Get())
|
||||
|
||||
@@ -160,8 +160,9 @@ func doInitQueryNodeOnce(ctx context.Context) error {
|
||||
cExprResCacheEnabled := C.bool(paramtable.Get().QueryNodeCfg.ExprResCacheEnabled.GetAsBool())
|
||||
C.SetExprResCacheEnable(cExprResCacheEnabled)
|
||||
|
||||
cExprResCacheCapacityBytes := C.int64_t(paramtable.Get().QueryNodeCfg.ExprResCacheCapacityBytes.GetAsInt64())
|
||||
C.SetExprResCacheCapacityBytes(cExprResCacheCapacityBytes)
|
||||
if paramtable.Get().QueryNodeCfg.ExprResCacheEnabled.GetAsBool() {
|
||||
UpdateExprResCacheConfig()
|
||||
}
|
||||
|
||||
C.SetArrowIOThreadPoolCapacity(C.int(ResolveArrowIOThreadPoolCapacity()))
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/milvus-io/milvus/internal/util/pathutil"
|
||||
"github.com/milvus-io/milvus/pkg/v3/config"
|
||||
"github.com/milvus-io/milvus/pkg/v3/log"
|
||||
"github.com/milvus-io/milvus/pkg/v3/util/hardware"
|
||||
@@ -91,8 +92,22 @@ func UpdateExprResCacheEnable(enable bool) {
|
||||
C.SetExprResCacheEnable(C.bool(enable))
|
||||
}
|
||||
|
||||
func UpdateExprResCacheCapacityBytes(capacity int) {
|
||||
C.SetExprResCacheCapacityBytes(C.int64_t(capacity))
|
||||
func UpdateExprResCacheConfig() {
|
||||
params := paramtable.Get()
|
||||
diskPath := pathutil.GetPath(pathutil.ExprCachePath, paramtable.GetNodeID())
|
||||
cMode := C.CString(params.QueryNodeCfg.ExprResCacheMode.GetValue())
|
||||
cDiskPath := C.CString(diskPath)
|
||||
defer C.free(unsafe.Pointer(cMode))
|
||||
defer C.free(unsafe.Pointer(cDiskPath))
|
||||
|
||||
C.SetExprResCacheConfig(cMode, cDiskPath,
|
||||
C.int64_t(params.QueryNodeCfg.ExprResCacheMemMaxBytes.GetAsInt64()),
|
||||
C.bool(params.QueryNodeCfg.ExprResCacheMemCompressionEnabled.GetAsBool()),
|
||||
C.int32_t(params.QueryNodeCfg.ExprResCacheAdmissionThreshold.GetAsInt32()),
|
||||
C.int64_t(params.QueryNodeCfg.ExprResCacheMinEvalDurationUs.GetAsInt64()),
|
||||
C.int64_t(params.QueryNodeCfg.ExprResCacheDiskMaxBytes.GetAsInt64()),
|
||||
C.int64_t(params.QueryNodeCfg.ExprResCacheDiskMaxFileSizeBytes.GetAsInt64()),
|
||||
C.int64_t(params.QueryNodeCfg.ExprResCacheMinEvalDurationUs.GetAsInt64()))
|
||||
}
|
||||
|
||||
func UpdateArrowIOThreadPoolCapacity(threads int) {
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
BM25Path
|
||||
RootCachePath
|
||||
FileResourcePath
|
||||
ExprCachePath
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
LocalChunkPathPrefix = "local_chunk"
|
||||
BM25PathPrefix = "bm25"
|
||||
FileResourcePathPrefix = "file_resource"
|
||||
ExprCachePathPrefix = "expr_cache"
|
||||
)
|
||||
|
||||
func GetPath(pathType PathType, nodeID int64) string {
|
||||
@@ -41,6 +43,8 @@ func GetPath(pathType PathType, nodeID int64) string {
|
||||
path = filepath.Join(path, fmt.Sprintf("%d", nodeID), BM25PathPrefix)
|
||||
case FileResourcePath:
|
||||
path = filepath.Join(path, fmt.Sprintf("%d", nodeID), FileResourcePathPrefix)
|
||||
case ExprCachePath:
|
||||
path = filepath.Join(path, fmt.Sprintf("%d", nodeID), ExprCachePathPrefix)
|
||||
case RootCachePath:
|
||||
}
|
||||
log.Info("Get path for", zap.Any("pathType", pathType), zap.Int64("nodeID", nodeID), zap.String("path", path))
|
||||
|
||||
@@ -3658,8 +3658,14 @@ type queryNodeConfig struct {
|
||||
EnableLatestDeleteSnapshotOptimization ParamItem `refreshable:"true"`
|
||||
|
||||
// expr cache
|
||||
ExprResCacheEnabled ParamItem `refreshable:"false"`
|
||||
ExprResCacheCapacityBytes ParamItem `refreshable:"false"`
|
||||
ExprResCacheEnabled ParamItem `refreshable:"true"`
|
||||
ExprResCacheMode ParamItem `refreshable:"true"`
|
||||
ExprResCacheMinEvalDurationUs ParamItem `refreshable:"true"`
|
||||
ExprResCacheAdmissionThreshold ParamItem `refreshable:"true"`
|
||||
ExprResCacheMemMaxBytes ParamItem `refreshable:"true"`
|
||||
ExprResCacheMemCompressionEnabled ParamItem `refreshable:"true"`
|
||||
ExprResCacheDiskMaxBytes ParamItem `refreshable:"true"`
|
||||
ExprResCacheDiskMaxFileSizeBytes ParamItem `refreshable:"true"`
|
||||
|
||||
// pipeline
|
||||
CleanExcludeSegInterval ParamItem `refreshable:"false"`
|
||||
@@ -4859,22 +4865,79 @@ user-task-polling:
|
||||
p.ExprResCacheEnabled = ParamItem{
|
||||
Key: "queryNode.exprCache.enabled",
|
||||
FallbackKeys: []string{"enable_expr_cache"},
|
||||
Version: "2.6.0",
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "false",
|
||||
Doc: "enable expression result cache",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheEnabled.Init(base.mgr)
|
||||
|
||||
p.ExprResCacheCapacityBytes = ParamItem{
|
||||
Key: "queryNode.exprCache.capacityBytes",
|
||||
FallbackKeys: []string{"max_expr_cache_size"},
|
||||
Version: "2.6.0",
|
||||
DefaultValue: "268435456", // 256MB
|
||||
Doc: "max capacity in bytes for expression result cache",
|
||||
p.ExprResCacheMode = ParamItem{
|
||||
Key: "queryNode.exprCache.mode",
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "disk",
|
||||
Doc: "cache mode: 'disk' (sealed segments only, pread/pwrite + fixed slots) or 'memory' (sealed and growing segments, malloc + Clock + compression)",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheCapacityBytes.Init(base.mgr)
|
||||
p.ExprResCacheMode.Init(base.mgr)
|
||||
|
||||
p.ExprResCacheMinEvalDurationUs = ParamItem{
|
||||
Key: "queryNode.exprCache.minEvalDurationUs",
|
||||
FallbackKeys: []string{"queryNode.exprCache.memory.minEvalDurationUs", "queryNode.exprCache.disk.minEvalDurationUs"},
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "1000",
|
||||
Doc: "global latency filter: skip expressions that eval faster than this (0=disabled)",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheMinEvalDurationUs.Init(base.mgr)
|
||||
|
||||
p.ExprResCacheMemMaxBytes = ParamItem{
|
||||
Key: "queryNode.exprCache.memory.maxBytes",
|
||||
FallbackKeys: []string{"queryNode.exprCache.maxTotalSizeBytes"},
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "268435456",
|
||||
Doc: "max memory for expression cache in memory mode (default 256MB)",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheMemMaxBytes.Init(base.mgr)
|
||||
|
||||
p.ExprResCacheMemCompressionEnabled = ParamItem{
|
||||
Key: "queryNode.exprCache.memory.compressionEnabled",
|
||||
FallbackKeys: []string{"queryNode.exprCache.compressionEnabled"},
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "true",
|
||||
Doc: "enable Roaring/Raw adaptive compression in memory mode",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheMemCompressionEnabled.Init(base.mgr)
|
||||
|
||||
p.ExprResCacheAdmissionThreshold = ParamItem{
|
||||
Key: "queryNode.exprCache.admissionThreshold",
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "2",
|
||||
Doc: "frequency admission for memory and disk mode: cache after N+ occurrences (1=no gating)",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheAdmissionThreshold.Init(base.mgr)
|
||||
|
||||
p.ExprResCacheDiskMaxBytes = ParamItem{
|
||||
Key: "queryNode.exprCache.disk.maxBytes",
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "10737418240",
|
||||
Doc: "max total disk usage for expression cache in disk mode (default 10GB)",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheDiskMaxBytes.Init(base.mgr)
|
||||
|
||||
p.ExprResCacheDiskMaxFileSizeBytes = ParamItem{
|
||||
Key: "queryNode.exprCache.disk.maxFileSizeBytes",
|
||||
FallbackKeys: []string{"queryNode.exprCache.maxSegmentFileSizeBytes"},
|
||||
Version: "3.0.0",
|
||||
DefaultValue: "268435456",
|
||||
Doc: "max file size per sealed segment in disk mode (default 256MB)",
|
||||
Export: true,
|
||||
}
|
||||
p.ExprResCacheDiskMaxFileSizeBytes.Init(base.mgr)
|
||||
|
||||
p.CleanExcludeSegInterval = ParamItem{
|
||||
Key: "queryCoord.cleanExcludeSegmentInterval",
|
||||
|
||||
Reference in New Issue
Block a user