Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ jobs:
files: codecov.json
fail_ci_if_error: true

macos:
# The only job that exercises the store's non-io_uring I/O backend and
# the portable (non-BMI2) selection path, both of which Linux never
# reaches. Excludes dev-tools, whose generated tailwind.css comes from
# the npm steps the Linux jobs run; benchmarks stay Linux-only because
# buffered I/O makes their numbers unrepresentative.
name: macOS
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci-${{ runner.os }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run clippy
run: cargo clippy --workspace --exclude dev-tools --all-targets --all-features -- -D warnings
- name: Run tests
run: cargo test --workspace --exclude dev-tools

shuttle_test:
name: Shuttle Test
runs-on: ubuntu-latest
Expand Down
2 changes: 0 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,20 @@ tokio_test::block_on(async {

### LiquidCache uses DIRECT I/O

By default, LiquidCache uses [DIRECT I/O](https://man7.org/linux/man-pages/man2/open.2.html#:~:text=O_DIRECT). This means that it bypasses the OS page cache, this avoids double-caching and bound memory usage.
On Linux, LiquidCache uses [DIRECT I/O](https://man7.org/linux/man-pages/man2/open.2.html#:~:text=O_DIRECT). This means that it bypasses the OS page cache, this avoids double-caching and bound memory usage.

This also means LiquidCache can *appear slower* than other caches when most data fits in OS page cache, which is common in dev environments but unrealistic in production.

### Platform support

LiquidCache builds, runs and passes its test suite on both Linux and macOS. DIRECT I/O is the exception: the underlying store implements it on Linux only, so every other target mounts with buffered I/O instead and logs a warning once at startup.

That fallback keeps the cache correct — it writes, reads and evicts exactly as on Linux — but it costs the property the accounting depends on. Under DIRECT I/O a cached page exists once and the cache knows about it. Under buffered I/O the kernel holds a second copy that the cache does not count, so reported memory understates real residency, and the admission gate's budget is measured against an incomplete figure.

So: **develop anywhere, measure on Linux.** Benchmark numbers from macOS are not comparable to production, and generally flatter LiquidCache rather than penalising it, since reads may be served from the page cache that DIRECT I/O deliberately avoids. Use a Linux machine or VM for any performance or capacity work.

Separately, and independent of the operating system: **compressed sizes differ slightly between arm64 and x86_64.** FSST picks its symbol table by draining a hash map into a priority queue, and its candidate ordering does not fully break ties, so equally-good symbols are chosen in hash-iteration order — which is not stable across architectures. The compressed output is valid and interchangeable either way, but a given column will not compress to exactly the same number of bytes on Graviton as on x86_64 (we measure ~0.4% on one test column). Compare compression ratios and capacity figures only within one architecture.


### Use LiquidCache with DataFusion

Expand Down
5 changes: 5 additions & 0 deletions benchmark/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ pprof = { version = "0.15.0", features = ["flamegraph"] }
anyhow = "1.0"
usdt = "0.6"
regex = "1.12.4"

# Hardware counters come from perf_event_open, a Linux-only syscall; the crate
# does not compile anywhere else. See `PerfEventCollector` in
# src/inprocess_runner.rs for the fallback.
[target.'cfg(target_os = "linux")'.dependencies]
perf-event2 = "0.7.4"

[features]
Expand Down
35 changes: 35 additions & 0 deletions benchmark/src/inprocess_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use liquid_cache::cache_policies::LiquidPolicy;
use liquid_cache_datafusion::{LiquidCacheParquetRef, extract_execution_metrics};
use liquid_cache_datafusion_local::LiquidCacheLocalBuilder;
use log::{info, warn};
#[cfg(target_os = "linux")]
use perf_event::{
Builder as PerfBuilder, Counter, Group,
events::{Hardware, Software},
Expand Down Expand Up @@ -72,6 +73,13 @@ impl DiskIoGuard {
}
}

/// Hardware and software counters for one query iteration.
///
/// `perf_event_open` is a Linux syscall, so off Linux the collector cannot be
/// constructed at all — see the uninhabited stub below. Callers already treat a
/// construction failure as "no counters this run", which is what we want:
/// absent counters rather than fabricated zeroes.
#[cfg(target_os = "linux")]
struct PerfEventCollector {
group: Group,
cycles: Counter,
Expand All @@ -82,6 +90,7 @@ struct PerfEventCollector {
page_faults: Counter,
}

#[cfg(target_os = "linux")]
impl PerfEventCollector {
fn new() -> io::Result<Self> {
let mut group = Group::new()?;
Expand Down Expand Up @@ -159,6 +168,32 @@ impl PerfEventCollector {
}
}

/// Stand-in for the collector on targets without `perf_event_open`.
///
/// Uninhabited, so the only reachable method is [`Self::new`], which always
/// fails. `start` and `stop` are therefore statically unreachable and need no
/// panic. Keeping the same shape as the Linux type means the call site is
/// identical on every platform.
#[cfg(not(target_os = "linux"))]
enum PerfEventCollector {}

#[cfg(not(target_os = "linux"))]
impl PerfEventCollector {
fn new() -> io::Result<Self> {
Err(io::Error::other(
"hardware counters need perf_event_open, which only Linux provides",
))
}

fn start(&mut self) -> io::Result<()> {
match *self {}
}

fn stop(self) -> io::Result<PerfEventStats> {
match self {}
}
}

#[derive(Clone, Debug, Default, Copy, PartialEq, Eq, Serialize)]
pub enum InProcessBenchmarkMode {
Parquet,
Expand Down
4 changes: 4 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
disallowed-methods = [
{ path = "t4::mount", reason = "use liquid_cache::store::mount so the store's I/O mode stays in one place" },
{ path = "t4::mount_with_options", reason = "use liquid_cache::store::mount so the store's I/O mode stays in one place" },
]
8 changes: 6 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@
llvmPackages.bintools
lldb
cargo-fuzz
bpftrace
perf
nixd
inferno
cargo-flamegraph
Expand All @@ -55,6 +53,12 @@
extensions = [ "rust-src" "llvm-tools-preview" ];
targets = [ "x86_64-unknown-linux-gnu" "wasm32-unknown-unknown" ];
}))
]
# perf and bpftrace exist only on Linux in nixpkgs, and this flake
# is evaluated for every default system, macOS included.
++ lib.optionals stdenv.isLinux [
bpftrace
perf
];

shellHook = ''
Expand Down
2 changes: 1 addition & 1 deletion src/core/src/cache/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ impl LiquidCacheBuilder {
None => {
let cache_dir = tempfile::tempdir().unwrap().keep();
let store_path = cache_dir.join("liquid_cache.t4");
t4::mount(&store_path)
crate::store::mount(&store_path)
.await
.expect("failed to mount t4 store")
}
Expand Down
6 changes: 3 additions & 3 deletions src/core/src/cache/tests/squeezed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async fn read_squeezed_date_time() {
.with_squeeze_policy(Box::new(TranscodeSqueezeEvict))
.with_max_memory_bytes(array_size * 2)
.with_store(
t4::mount(temp_dir.path().join("liquid_cache.t4"))
crate::store::mount(temp_dir.path().join("liquid_cache.t4"))
.await
.unwrap(),
)
Expand Down Expand Up @@ -95,7 +95,7 @@ async fn read_squeezed_variant_path() {
.with_squeeze_policy(Box::new(TranscodeSqueezeEvict))
.with_max_memory_bytes(array_size * 3 / 2)
.with_store(
t4::mount(temp_dir.path().join("liquid_cache.t4"))
crate::store::mount(temp_dir.path().join("liquid_cache.t4"))
.await
.unwrap(),
)
Expand Down Expand Up @@ -158,7 +158,7 @@ async fn read_squeezed_int64_array() {
.with_squeeze_policy(Box::new(TranscodeSqueezeEvict))
.with_max_memory_bytes(array_size * 2)
.with_store(
t4::mount(temp_dir.path().join("liquid_cache.t4"))
crate::store::mount(temp_dir.path().join("liquid_cache.t4"))
.await
.unwrap(),
)
Expand Down
1 change: 1 addition & 0 deletions src/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

pub mod cache;
pub mod liquid_array;
pub mod store;
mod sync;
pub mod utils;

Expand Down
87 changes: 87 additions & 0 deletions src/core/src/store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! Mounting the on-disk store that backs the cache's disk tier.
//!
//! LiquidCache wants DIRECT I/O. Bypassing the OS page cache is what makes the
//! cache's own byte accounting the whole truth: one copy of a cached page
//! exists, and the cache knows about it. The admission gate
//! ([`crate::cache`] budgets, and `liquid-cache-datafusion`'s footprint gate)
//! is built on that premise.
//!
//! [`t4`] only implements DIRECT I/O on Linux — every other target refuses the
//! option outright rather than silently ignoring it. So off Linux we mount
//! buffered and say so. The cache stays correct: it writes, reads and evicts
//! exactly as before. What it loses is the accounting guarantee, because the
//! kernel now keeps a second copy of every page that the cache does not count.
//! That makes non-Linux fine for development and wrong for measurement.

use std::path::Path;

/// Mount the on-disk store for a LiquidCache instance at `path`, which is the
/// full path to the store file.
///
/// Prefer this over calling [`t4::mount`] directly: it is the one place that
Comment thread
zfarrell marked this conversation as resolved.
/// decides the store's I/O mode, so the choice cannot drift between the cache
/// builders, the server, benches and tests.
///
/// On Linux this is exactly [`t4::mount`] — `direct_io` and `dsync` both come
/// out `true`, matching [`t4::MountOptions::default`]. See the
/// [module docs](self) for what the buffered fallback costs elsewhere.
// The one place allowed to mount through `t4` directly — `clippy.toml` sends
// every other call site here, so this is where the `disallowed_methods` rule has
// to stop.
#[allow(clippy::disallowed_methods)]
pub async fn mount(path: impl AsRef<Path>) -> t4::Result<t4::Store> {
#[cfg(not(target_os = "linux"))]
warn_buffered_once();

// `dsync` stays at t4's default: O_DSYNC is honoured off Linux too, so the
// fallback keeps the write-durability semantics Linux gets.
t4::mount_with_options(
path,
t4::MountOptions {
direct_io: cfg!(target_os = "linux"),
..Default::default()
},
)
.await
}

#[cfg(not(target_os = "linux"))]
fn warn_buffered_once() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
log::warn!(
"mounting the liquid cache store with buffered I/O: t4 implements DIRECT I/O on Linux \
only. The cache is fully functional, but memory accounting now excludes the kernel \
page-cache copy of every cached page, so reported usage understates real residency \
and the admission gate's budget is measured against an incomplete figure. Measure \
performance on Linux."
);
});
}

#[cfg(test)]
mod tests {
use super::*;

/// The store must mount, round-trip a value and survive a remount on every
/// platform. On Linux this covers t4's io_uring backend; elsewhere it is the
/// only coverage the generic thread-pool backend gets.
#[tokio::test]
async fn mount_round_trips_on_this_platform() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("liquid_cache.t4");

let store = mount(&path).await.expect("mount must succeed");
store.put(b"key".to_vec(), b"hello".to_vec()).await.unwrap();
assert_eq!(store.get(b"key").await.unwrap(), b"hello");
store.sync().await.unwrap();
drop(store);

let store = mount(&path).await.expect("remount must succeed");
assert_eq!(
store.get(b"key").await.unwrap(),
b"hello",
"a remounted store must replay what was written"
);
}
}
3 changes: 2 additions & 1 deletion src/core/study/cache_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ fn main() {
.clone()
.unwrap_or_else(|| tempfile::tempdir().unwrap().keep());
let store_path = cache_dir.join("liquid_cache.t4");
let store = tokio_test::block_on(t4::mount(&store_path)).expect("failed to mount t4 store");
let store = tokio_test::block_on(liquid_cache::store::mount(&store_path))
.expect("failed to mount t4 store");
let storage = tokio_test::block_on(async {
LiquidCacheBuilder::new()
.with_max_memory_bytes(500 * 1024 * 1024)
Expand Down
1 change: 0 additions & 1 deletion src/datafusion-local/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ arrow = { workspace = true }
arrow-schema = { workspace = true }
tokio = { workspace = true }
fastrace = { workspace = true }
t4 = { workspace = true }


[dev-dependencies]
Expand Down
16 changes: 3 additions & 13 deletions src/datafusion-local/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,19 +185,9 @@ impl LiquidCacheLocalBuilder {
config.options_mut().execution.parquet.skip_metadata = false;
config.options_mut().execution.batch_size = self.batch_size;

// t4's default MountOptions enable direct_io, which only Linux
// supports; everywhere else the mount fails outright. Keep direct_io
// on Linux (production) and fall back to buffered I/O elsewhere so
// local mode — and its tests — run on macOS dev machines.
let store = t4::mount_with_options(
self.cache_dir.join("liquid_cache.t4"),
t4::MountOptions {
direct_io: cfg!(target_os = "linux"),
..Default::default()
},
)
.await
.map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?;
let store = liquid_cache::store::mount(self.cache_dir.join("liquid_cache.t4"))
.await
.map_err(|e| datafusion::error::DataFusionError::External(Box::new(e)))?;
#[cfg(not(test))]
let cache = LiquidCacheParquet::new(
self.batch_size,
Expand Down
Loading
Loading