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
3 changes: 1 addition & 2 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ src/
├── collection/ # collection (re)creation: from CLI flags / from YAML config
├── generators/ # data generation: points (legacy & config), queries, random primitives
├── search/ # search benchmarking processors (flag-driven & config-driven)
├── serverless/ # multi-collection mode for Qdrant Serverless (upload/clear/query)
├── upload.rs # upload pipeline (parallelism, batching, progress)
├── upsert.rs # upsert request execution + timing
├── scroll.rs # scroll benchmarking processor
Expand Down
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,77 @@ and search configs. The CLI still controls *how* the benchmark runs (`-n`, `-p`,
The flag-driven path (`bfb --scroll --keywords 100 …`) is still available when
you prefer flat CLI flags over a YAML file.

### `serverless` — multi-collection benchmarks against Qdrant Serverless

Serverless uses a **collection per tenant**, so the interesting workload is
traffic spread across many collections rather than one shared collection with
tenant payload. `bfb serverless` talks to the space through the
[`QdrantServerless`](https://github.com/qdrant/rust-client) client. It takes
the same connection flags as the rest of bfb (`--uri`, `--connections`,
`--timeout`, API key via `QDRANT_API_KEY`); a `--uri` without an explicit port
defaults to 443.

```bash
# Upload 10M points across 100 collections (created lazily on first upsert)
bfb serverless upload \
--uri https://serverless.example.cloud.qdrant.io \
--collection-prefix benchmark- \
--collections-count 100 \
--distribution uniform \
--total-points 10M \
--example serverless-upload \
-b 64 -p 8

# Query existing collections; query shape is derived from a collection's config
bfb serverless query \
--uri https://serverless.example.cloud.qdrant.io \
--collection-prefix benchmark- \
--distribution zipf \
-n 10k --rps 200 --json results.json

# Same, but with a search YAML (filters, sparse queries, dataset query sets …)
bfb serverless query \
--uri https://serverless.example.cloud.qdrant.io \
--collection-prefix benchmark- \
--file search.yaml -n 10k -p 8

# Show what is there: one line per collection with its point count
bfb serverless list \
--uri https://serverless.example.cloud.qdrant.io \
--collection-prefix benchmark-

# Tear down everything with that prefix
bfb serverless clear \
--uri https://serverless.example.cloud.qdrant.io \
--collection-prefix benchmark-
```

| Flag | Meaning |
|------|---------|
| `--collection-prefix` | Shared name prefix (`benchmark-` → `benchmark-0` … `benchmark-99`). Optional for `list`, which then shows the whole space |
| `--collections-count` | Upload only: how many collection slots to spread points across |
| `--distribution` | `uniform` or `zipf` — how points/queries are routed across slots |
| `--total-points` | Upload only: total points across all collections (falls back to `-n`) |
| `--file` / `--example` | Upload: collection shape (`bfb upload` schema, required). Query: request shape (`bfb search` schema, optional) |

The run itself is controlled by the usual global flags: `-p` / `--rps` /
`--throttle`, `-b`, `--search-batch-size`, `--search-limit`, `--retries`,
`--ignore-errors`, `--jsonl-updates` / `--jsonl-searches` / `--jsonl-rps`, and
`--json` for the unified results document.

Collections are **not** created up front. On first upsert to a slot, bfb creates
it from the upload YAML (only the tenant-facing subset: dense/sparse vectors and
payload indexes — HNSW/quantization/on-disk knobs are ignored and the precision
tier is left at the service default). A slot that already exists is reused
after checking its vectors match the YAML. Point ids are contiguous across
collections (starting at `--offset`), so with a dataset source every
collection receives a different slice of the data. A registry prints how many
collections were preexisting / created / used after the run.

`query` only targets collections that hold points. Without a search YAML it
issues random dense and sparse queries matching the vectors it finds in the
first collection's config.

### `schema` — print the upload-config file schema

Print an annotated YAML reference enumerating every option accepted by an
Expand Down
26 changes: 26 additions & 0 deletions examples/serverless-upload.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Minimal upload shape for `bfb serverless upload`.
#
# bfb serverless upload \
# --uri https://serverless.example.cloud.qdrant.io \
# --collection-prefix benchmark- \
# --collections-count 100 \
# --distribution uniform \
# --total-points 1M \
# --example serverless-upload
#
# Only the tenant-facing subset is sent to serverless (dense/sparse vectors +
# payload indexes). HNSW / quantization / on-disk knobs are ignored by the
# converter — keep them if you also reuse a file with `bfb upload`.

collection:
vectors:
- size: 128
distance: cosine
source: random

fields:
- name: color
type: keyword
source:
type: random
cardinality: 100
8 changes: 7 additions & 1 deletion src/args/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,12 @@ pub enum Command {
#[clap(value_enum)]
shell: clap_complete::Shell,
},

/// Benchmark Qdrant Serverless (multi-collection / per-tenant mode).
///
/// Spreads upload and query traffic across a range of collections instead
/// of a single shared collection. See `bfb serverless --help`.
Serverless(crate::serverless::ServerlessArgs),
}

/// `--file` or `--example` (exactly one required). Flattened into upload /
Expand Down Expand Up @@ -603,7 +609,7 @@ impl Args {
}
}

fn parse_number(n: &str) -> Result<usize, String> {
pub(crate) fn parse_number(n: &str) -> Result<usize, String> {
parse_number_impl(n)
.and_then(|v| v.try_into().ok())
.ok_or_else(|| format!("Invalid number: {n}"))
Expand Down
14 changes: 11 additions & 3 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,18 @@ pub fn create_clients(args: &Args) -> Result<Vec<Qdrant>> {

/// Try the request on every client in random order, retrying `args.retries`
/// times with `args.retry_interval` between rounds.
pub async fn retry_with_clients<'a, R, T: std::future::Future<Output = Result<R, QdrantError>>>(
clients: &'a [Qdrant],
///
/// Generic over the client type so the regular [`Qdrant`] client and the
/// serverless client share one retry policy.
pub async fn retry_with_clients<
'a,
C,
R,
T: std::future::Future<Output = Result<R, QdrantError>>,
>(
clients: &'a [C],
args: &Args,
mut call: impl FnMut(&'a Qdrant) -> T,
mut call: impl FnMut(&'a C) -> T,
) -> anyhow::Result<R> {
let mut rng = rand::rng();
let mut permutation = (0..clients.len()).collect::<Vec<_>>();
Expand Down
5 changes: 5 additions & 0 deletions src/config/examples.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ pub static EXAMPLES: &[Example] = &[
Upload,
"Full LAION-400M corpus (~410 parts, streamed with cache: evict)"
),
example!(
"serverless-upload",
Upload,
"Minimal upload shape for `bfb serverless upload` (dense + keyword)"
),
example!(
"search-config",
Search,
Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod save_jsonl;
mod scroll;
mod search;
mod self_update;
mod serverless;
mod stats;
mod upload;
mod upsert;
Expand Down Expand Up @@ -129,6 +130,9 @@ async fn run_benchmark(args: Args, stopped: Arc<AtomicBool>) -> Result<()> {
Some(Command::Search(search_args)) => return run_search(args, search_args, stopped).await,
Some(Command::Upload(upload_args)) => return run_upload(args, upload_args, stopped).await,
Some(Command::Scroll(scroll_args)) => return run_scroll(args, scroll_args, stopped).await,
Some(Command::Serverless(serverless_args)) => {
return serverless::run(args, serverless_args, stopped).await;
}
// `Schema` / `SelfUpdate` / `Completions` are handled before the runtime
// starts; `None` falls through.
Some(
Expand Down
119 changes: 119 additions & 0 deletions src/serverless/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! CLI for `bfb serverless {upload,clear,query}`.

use clap::{Args as ClapArgs, Subcommand, ValueEnum};

use super::distribution::Distribution;
use crate::args::ConfigArgs;

/// `bfb serverless` — multi-collection benchmarks against Qdrant Serverless.
#[derive(ClapArgs, Debug, Clone)]
pub struct ServerlessArgs {
#[command(subcommand)]
pub command: ServerlessCommand,
}

#[derive(Subcommand, Debug, Clone)]
pub enum ServerlessCommand {
/// Upload points across a range of collections (created lazily on first use).
Upload(ServerlessUploadArgs),

/// Delete every collection whose name starts with `--collection-prefix`.
Clear(ServerlessClearArgs),

/// List collections and their point counts.
List(ServerlessListArgs),

/// Run queries routed across existing collections matching the prefix.
Query(ServerlessQueryArgs),
}

#[derive(ClapArgs, Debug, Clone)]
pub struct ServerlessUploadArgs {
/// Prefix shared by every collection name (`benchmark-` → `benchmark-0` …).
#[clap(long)]
pub collection_prefix: String,

/// How many collections to spread points across.
#[clap(long, value_parser = crate::args::parse_number)]
pub collections_count: usize,

/// How points are allocated across collections.
#[clap(long, value_enum, default_value_t = DistributionArg::Uniform)]
pub distribution: DistributionArg,

/// Total number of points to upload across all collections.
/// Falls back to the global `-n` / `--num-vectors` when omitted.
#[clap(long, value_parser = crate::args::parse_number)]
pub total_points: Option<usize>,

/// Upload-shape YAML: `--file <path>` or `--example <name>` (same schema
/// as `bfb upload`).
#[clap(flatten)]
pub config: ConfigArgs,
}

#[derive(ClapArgs, Debug, Clone)]
pub struct ServerlessClearArgs {
/// Delete every collection whose name starts with this prefix.
#[clap(long)]
pub collection_prefix: String,
}

#[derive(ClapArgs, Debug, Clone)]
pub struct ServerlessListArgs {
/// Only list collections whose name starts with this prefix.
/// Omit to list every collection in the space.
#[clap(long, default_value = "")]
pub collection_prefix: String,
}

#[derive(ClapArgs, Debug, Clone)]
pub struct ServerlessQueryArgs {
/// Query every existing collection whose name starts with this prefix.
#[clap(long)]
pub collection_prefix: String,

/// How queries are routed across matching collections.
#[clap(long, value_enum, default_value_t = DistributionArg::Uniform)]
pub distribution: DistributionArg,

/// Optional search-shape YAML (same schema as `bfb search`). When omitted,
/// one random dense or sparse query template is derived from a matching
/// collection's config.
#[clap(flatten)]
pub config: OptionalConfigArgs,
}

/// `--file` or `--example`, both optional (at most one).
#[derive(ClapArgs, Debug, Clone)]
#[group(multiple = false)]
pub struct OptionalConfigArgs {
/// Path to a YAML config file
#[clap(long)]
pub file: Option<String>,

/// Built-in example name (`bfb examples` lists them)
#[clap(long, value_name = "NAME")]
pub example: Option<String>,
}

impl OptionalConfigArgs {
pub fn is_some(&self) -> bool {
self.file.is_some() || self.example.is_some()
}
}

#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum DistributionArg {
Uniform,
Zipf,
}

impl From<DistributionArg> for Distribution {
fn from(value: DistributionArg) -> Self {
match value {
DistributionArg::Uniform => Distribution::Uniform,
DistributionArg::Zipf => Distribution::Zipf,
}
}
}
44 changes: 44 additions & 0 deletions src/serverless/clear.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//! `bfb serverless clear` — delete collections matching a prefix.

use anyhow::Result;

use super::args::ServerlessClearArgs;
use super::client::single_client;
use super::collections::list_matching;
use crate::args::Args;

pub async fn run(args: &Args, clear: ServerlessClearArgs) -> Result<()> {
let client = single_client(args)?;
let names: Vec<String> = list_matching(&client, &clear.collection_prefix)
.await?
.into_iter()
.map(|c| c.collection_name)
.collect();

if names.is_empty() {
println!(
"No collections matched prefix {:?}",
clear.collection_prefix
);
return Ok(());
}

println!(
"Deleting {} collection(s) with prefix {:?}",
names.len(),
clear.collection_prefix
);

let mut deleted = 0usize;
for name in &names {
let ok = client.delete_collection(name).await?;
if ok {
deleted += 1;
println!(" deleted {name}");
} else {
println!(" skipped {name} (already gone)");
}
}
println!("Deleted {deleted}/{} collections", names.len());
Ok(())
}
27 changes: 27 additions & 0 deletions src/serverless/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Build [`QdrantServerless`] clients from shared BFB [`Args`].
//!
//! Reuses the regular client configuration (`--uri` × `--connections`,
//! `--timeout`, `QDRANT_API_KEY`) so both modes are configured the same way.

use anyhow::Result;
use qdrant_client::serverless::QdrantServerless;

use crate::args::Args;
use crate::client::get_config;

/// One serverless client per (`uri` × `connections`) pair, matching regular BFB.
pub fn create_clients(args: &Args) -> Result<Vec<QdrantServerless>> {
get_config(args)
.into_iter()
.map(|config| Ok(QdrantServerless::new(config)?))
.collect()
}

/// A single client for one-off administrative calls (`clear`, listing).
pub fn single_client(args: &Args) -> Result<QdrantServerless> {
let config = get_config(args)
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("no --uri given"))?;
Ok(QdrantServerless::new(config)?)
}
Loading
Loading