Back to Rustfs

Object I/O (GET/PUT) tuning A/B matrix runbook

docs/operations/object-io-tuning-ab-matrix.md

1.0.0-rc.212.9 KB
Original Source

Object I/O (GET/PUT) tuning A/B matrix runbook

Scope: parameter tuning — measuring the effect of changing one RUSTFS_* runtime knob at a time, against a fixed binary. This is deliberately different from the code-change A/B gate in hotpath-warp-ab-runbook.md and the formal ABBA validation in hotpath-warp-abba-runbook.md, which compare a baseline binary against a candidate binary.

When a knob change turns out to need a code change, use those two runbooks for the code-level validation and come back here for the knob-level sweep.

1. What this runbook answers

For each tuning knob it answers three questions:

  1. Which stage is actually slow — set_disk_encode, set_disk_rename, metadata_fanout, bitrot_verify, etc.?
  2. Is the knob the real bottleneck, or is the stage slow for another reason?
  3. Does widening/loosening the knob buy throughput without an unacceptable memory (RSS) or tail-latency regression?

The core discipline is one variable per A/B cell. Never change two knobs in the same cell, or the result is unexplainable.

2. Prerequisites

  • Linux bench host (or an ansible-managed cluster); a laptop smoke run is too noisy to decide anything.
  • warp on PATH (or pass --warp-bin to the driver).
  • The observability metrics runtime enabled. The stage histograms below are not emitted when RUSTFS_OBS_METRICS_EXPORT_ENABLED=false or the runtime is otherwise off — see hotpath-warp-ab-runbook.md for the no-log / no-monitor baseline env.
  • A warm, disposable data set. Recreate the bucket per run; do not bench against production data.

Load driver and gate are reused, not reimplemented:

  • scripts/run_object_batch_bench_enhanced.sh — warp driver with rounds, median aggregation, baseline_compare.csv, and Prometheus service-metric capture.
  • scripts/hotpath_warp_ab_gate.sh — relative budget gate over the deltas.
  • scripts/run_hotpath_warp_ab.sh — optional orchestrator when a knob needs the full baseline-vs-candidate treatment (e.g. two different defaults).

3. Fixed test conditions (lock before you start)

Record these per run; a result without them is not reproducible:

text
nodes, disks_per_node, total_disks, cpu_per_node, mem_per_node,
network, erasure_set_drive_count, endpoint_mode (direct|lb),
rustfs_commit_sha, warp --version, durability mode

Workload matrix (the same shapes the hotpath gate uses, expanded for the stage-breakdown object sizes):

Workloadmodesizes
small-fixedput / get4KiB, 100KiB
ec-boundaryput / get1MiB, 4MiB
large-streamput / get10MiB, 16MiB, 32MiB
mixedmixed256KiB

Concurrency ladder: 8, 16, 32, 64 (add 96, 128 on a bigger rig). Duration 120s, --rounds >= 3, cooldown >= 30s.

Isolate background noise before the sweep: scanner deep-verify, heal, replication, lifecycle transition, periodic capacity refresh — record whether each is on rather than silently assuming it is off.

4. Measurement stack

The code already instruments every stage below. Drive each A/B cell with these histograms (names verified against crates/io-metrics/src/lib.rs):

  • PUT stages: rustfs_s3_put_object_stage_duration_ms{stage=...} — compute P50/P95/P99 per stage. Stages: app_bucket_validate, app_sse_config_lookup, app_object_lock_config_lookup, app_put_opts_build, app_prelookup, ingress_prepare, app_encryption_prepare, app_replication_decision, app_store_put, app_post_store_bookkeeping, app_capacity_update, set_disk_writer_setup, set_disk_encode, set_disk_rename, set_disk_old_data_cleanup.
  • GET stages: rustfs_io_get_object_stage_duration_seconds{path=..., stage=...} — the path label separates the read paths: legacy_duplex, codec_streaming, direct_memory, body_cache, inline_direct, internal_meta, remote_transition, set_disk, empty. Stages: metadata, metadata_cache_lookup, metadata_fanout, metadata_resolve, object_info, path_decision, quorum_reached, range, reader_setup, stripe_read, stripe_read_first_shard, stripe_read_quorum, decode, reconstruct, emit, fill, output_poll, output_lock_wait, bitrot_verify, first_byte, full_body, response_handoff, lock_acquire.
  • EC memory pressure: rustfs_ec_encode_inflight_bytes_current and the allocator reclaim gauge; plus node RSS and CPU.

Host telemetry (collect alongside every cell):

bash
pidstat -durh 5 > telemetry/pidstat.txt &
mpstat 5 > telemetry/mpstat.txt &
iostat -xz 5 > telemetry/iostat.txt &

5. Tuning knob catalog

Defaults are verified against crates/config/src/constants/object.rs and crates/ecstore/src/erasure/coding/encode.rs.

5.1 PUT

KnobDefaultControlsValidating stageRisk if widened
RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES32MiBEC encode producer/consumer memory budget (blocks queued between encode and shard write)set_disk_encode P95 + rustfs_ec_encode_inflight_bytes_currentRSS growth under high concurrency
RUSTFS_OBJECT_IO_BUFFER_SIZE128KiBStreaming read-in / write-out block sizeingress_prepare, set_disk_encodeLarger buffers = fewer polls, more resident memory
RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE4MiBduplex pipe capacity (shared, but PUT path uses it less than GET)set_disk_encode feed smoothnessMemory per in-flight request
RUSTFS_DURABILITY_MODE / RUSTFS_DRIVE_SYNC_ENABLEmode-dependentper-shard fsync/sync discipline on commitset_disk_rename P99Weakening it changes the durability contract — treat as a deliberate tradeoff, not a free win
RUSTFS_RUNTIME_WORKER_THREADS / RUSTFS_RUNTIME_MAX_BLOCKING_THREADSTokio defaultsasync workers + spawn_blocking pool feeding per-block encodeset_disk_encode P95 + mpstatOversubscription

5.2 GET

KnobDefaultControlsValidating stageRisk if enabled
RUSTFS_GET_CODEC_STREAMING_ROLLOUToffswitches the read path from legacy_duplex to the pull-based ErasureDecodeReader (codec_streaming)compare path="legacy_duplex" vs path="codec_streaming" for decode/emit/output_lock_wait/stripe_readbehavioral change to the read path; rollout is off by default for a reason
RUSTFS_GET_CODEC_STREAMING_ENGINElegacylegacy vs rustfs decode engine under the streaming readerreconstruct/decode per pathengine swap on a correctness-critical path
RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLEfalsemultipart objects on the streaming readersame, multipart cellswider format coverage
RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE (+ _MAX_SIZE, _FIRST_READER_SETUP)false / 512KiBprefer data-shard readers before paritystripe_read_first_shard/stripe_read_quorumshard-selection order change
RUSTFS_OBJECT_GET_SKIP_BITROT_VERIFYfalseskip per-shard HighwayHash verifybitrot_verifydo not default on — measures the theoretical ceiling only
RUSTFS_OBJECT_DUPLEX_BUFFER_SIZE4MiBlegacy GET in-process pipe capacityoutput_lock_wait/output_pollmemory per in-flight GET
RUSTFS_GET_SEEK_BUFFER_ENABLEfalsein-memory seek buffer for small GETfirst_byteexperimental, startup-latched — see get-path-experimental-switches.md
RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLEfalseadds response_handoff attribution (metrics only)response_handoffsmall per-request bookkeeping cost

6. A/B matrix

Run each row as an independent cell. Baseline is the shipped default; candidate is one knob moved. Everything else (topology, sizes, concurrency, rounds, durability) stays fixed.

6.1 PUT

#KnobBaselineCandidatesSignal metricJudgement
P1encode in-flight32MiB48MiB, 64MiB, 96MiBset_disk_encode P95 + throughput + RSSthroughput up AND set_disk_encode down AND RSS tolerable → budget was the binding constraint
P2object I/O buffer128KiB256KiB, 512KiB, 1MiBingress_prepare + set_disk_encodeencode smooths with bounded RSS → upstream feed was too small
P3duplex buffer4MiB8MiB, 16MiBset_disk_encode feed variancelower priority than P1/P2
P4blocking threadsdefault512, 768, 1024set_disk_encode P95 + mpstatper-block spawn_blocking is scheduler-bound if P95 falls
P5durabilitycurrent modeweaker/stronger modeset_disk_rename P99only as a deliberate durability tradeoff, never a silent default change
P6set drive countcurrentother valid widthsall PUT stageshighest cost — only after P1–P5, and only on a rebuildable topology

6.2 GET

#KnobBaselineCandidatesSignal metricJudgement
G1codec streaming rolloutoffon (pct ramp 10/50/100)path="legacy_duplex" vs path="codec_streaming" for decode/emit/output_lock_wait/stripe_read + throughputstreaming beats duplex on decode+output_lock_wait and byte-for-byte output matches → candidate for default
G2codec streaming enginelegacyrustfsreconstruct/decode per pathengine swap is neutral-or-better on CPU with identical bytes
G3multipart streamingfalsetruemultipart GET cellsonly after G1 is stable on single-part
G4data-blocks-firstfalsetruestripe_read_first_shard/stripe_read_quorumfewer shard reads without a correctness regression
G5duplex buffer4MiB8MiB, 16MiBoutput_lock_wait/output_poll (legacy path)only if still on legacy_duplex
G6skip bitrot verifyfalsetruebitrot_verifyceiling measurement only; do not carry into production

7. Execution sequence

  1. Freeze the conditions in §3 and record the provenance block.
  2. Run the baseline cell (all defaults) and capture stage histograms + host telemetry.
  3. Pick the one most-likely knob from the analysis. For large-object PUT that is almost always set_disk_encode → P1; for GET it is G1 (the legacy_duplexcodec_streaming switch).
  4. Sweep that knob's candidate column one value at a time, same workload.
  5. Read the decision table in §8; if the stage did not move, the knob is not the bottleneck — stop widening it and pick the next stage.

Driver invocation for one PUT cell:

bash
scripts/run_object_batch_bench_enhanced.sh \
  --tool warp --endpoint http://127.0.0.1:9000 \
  --access-key "$RUSTFS_ACCESS_KEY" --secret-key "$RUSTFS_SECRET_KEY" \
  --bucket rustfs-put-tuning --warp-mode put \
  --sizes 16MiB,32MiB --concurrency 32 --duration 120s --rounds 3 \
  --out-dir target/bench/put-tuning-p1-64mib

8. Interpretation / decision table

Stage highMost likely causeNext action
set_disk_encodeper-block EC encode scheduling + in-flight budgetP1 → P4 → P2, in that order
set_disk_renamecommit tail (rename fan-out / RPC / fsync)P5 (durability) and cluster tail analysis, not encode
set_disk_writer_setupper-disk BitrotWriter + temp-file createdisk/filesystem metadata; per-disk fan-out cost
set_disk_old_data_cleanupoverwrite / versioned-object directory deleteconfirm overwrite-vs-new-write; defer cleanup further
metadata_fanout / metadata_resolvecross-disk xl.meta read + quorummetadata cache hit rate; small-object fixed cost
bitrot_verifyHighwayHash verify on the read pathG6 ceiling only; do not default on
output_lock_wait / output_polllegacy duplex backpressureG1 (move off duplex) or G5
stripe_read*shard concurrency / selectionG4 shard-selection, disk/network tail

9. Guardrails

  • Never weaken correctness for throughput: read/write quorum, bitrot verify, xl.meta validation, and durability (RUSTFS_DURABILITY_MODE) are integrity contracts, not knobs. P5 and G6 are ceiling measurements and must be labelled as such; do not carry their values into production without an explicit durability/correctness decision.
  • One variable per cell. A cell that changes two knobs is thrown away.
  • Memory is part of the result. A throughput win with unbounded RSS growth is a regression; record RSS and the EC in-flight gauge for every PUT cell.
  • Startup-latched knobs (RUSTFS_GET_SEEK_BUFFER_ENABLE, RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE, and the codec-streaming switches) require a process restart to change — see get-path-experimental-switches.md.
  • Archive the raw data. Keep the baseline_compare.csv, median_summary.csv, stage histograms, and host telemetry per cell; the conclusion must trace back to them. Do not commit benchmark result snapshots to the repo — record them in the issue tracker.