Skip to content

common/crypto: cache EVP_MD_fetch() result for non-FIPS MD5 - #71062

Open
JBakamovic wants to merge 1 commit into
ceph:mainfrom
JBakamovic:jbakamovic/common-crypto-cache-evp-md-fetch
Open

common/crypto: cache EVP_MD_fetch() result for non-FIPS MD5#71062
JBakamovic wants to merge 1 commit into
ceph:mainfrom
JBakamovic:jbakamovic/common-crypto-cache-evp-md-fetch

Conversation

@JBakamovic

Copy link
Copy Markdown

On OpenSSL 3, OpenSSLDigest::SetFlags(EVP_MD_CTX_FLAG_NON_FIPS_ALLOW) calls EVP_MD_fetch(NULL, "MD5", "fips=no") every time it runs. rgw constructs an MD5 digest and calls SetFlags() once per object write to compute the etag (RGWPutObj::execute and friends), so under small object workloads this performs a provider-store fetch per request. EVP_MD_fetch() takes the provider store lock and evaluates the property query, and OpenSSL documents the intended usage as fetch once, use many times.

Fetch the algorithm once per process instead and hand each digest instance its own reference via EVP_MD_up_ref(), keeping the EVP_MD_free() in the destructor balanced. EVP_MD objects are reference-counted and safe to share across threads.

Note that using the legacy static EVP_md5() instead would not avoid the cost: on OpenSSL 3 initializing a digest context from a legacy static EVP_MD performs an implicit fetch on every EVP_DigestInit_ex(), which benchmarks the same as the explicit per-instance fetch (see "implicit" below). Caching the fetched EVP_MD is the only variant that avoids the per-request provider-store traffic. The other ceph_crypto digests (SHA1/SHA256/SHA512, used per-request by e.g. rgw's SigV4 code via the implicit-fetch path) could benefit from the same treatment as a follow-up.

Microbenchmark (source in the PR description): each iteration performs one complete etag-style digest, exactly as rgw does per PUT: allocate an EVP_MD_CTX, obtain the MD5 EVP_MD, EVP_DigestInit_ex(), hash a 4KB payload, EVP_DigestFinal_ex(), free the context. The three modes differ only in how the EVP_MD is obtained:

  • "fetch": EVP_MD_fetch(NULL, "MD5", "fips=no") per iteration, EVP_MD_free() afterwards -- the current behavior, where every digest instance performs its own provider-store fetch;
  • "cached": EVP_MD_fetch() once into a function-local static, then EVP_MD_up_ref() per iteration -- the behavior with this change;
  • "implicit": the legacy static EVP_md5() -- no explicit fetch, but on OpenSSL 3 EVP_DigestInit_ex() performs an implicit fetch internally on every init.

N threads run the loop concurrently and share no state beyond what libcrypto itself shares. Numbers are wall-clock ns per digest operation (lower is better), 200K iterations per thread, libcrypto 3.2.4, shown as fetch -> cached:

CPU 1 thread 8 threads 32 threads
Ryzen 9 9955HX 16C/32T 3819 -> 3724 567 -> 500 311 -> 197
Xeon Gold 6152 22C/44T 6506 -> 6344 1131 -> 990 686 -> 583

Reading the numbers: at 1 thread the variants differ by only ~2.5%, i.e. the uncontended fetch is cheap. The gap widens with concurrency: +13%/+14% ops/s at 8 threads, +58%/+18% ops/s at 32 threads. That pattern is the provider store lock serializing concurrent fetches: the cost is contention rather than per-call latency, so it grows with exactly the parameter (concurrent requests per daemon) that loaded radosgw deployments maximize. The "implicit" mode benchmarks the same as "fetch" on both machines (Ryzen 32T: 314 vs 311 ns/op), confirming that switching to the legacy EVP_md5() static would not avoid the contention; caching the fetched EVP_MD is what removes it.

End-to-end behavior is unchanged: s3-tests boto3 functional smoke passes against a vstart cluster with this change (every PUT's etag is an MD5 that clients verify). Profiling radosgw under a small vstart PUT workload shows no measurable throughput difference, as expected: such a setup is IO-bound and rgw CPU is not the bottleneck there. The change targets CPU-saturated, many-threaded radosgw deployments.

Signed-off-by: Jusufadis Bakamovic jusufadis.bakamovic@clyso.com

Contribution Guidelines

  • To sign and title your commits, please refer to Submitting Patches to Ceph.

  • If you are submitting a fix for a stable branch (e.g. "quincy"), please refer to Submitting Patches to Ceph - Backports for the proper workflow.

  • When filling out the below checklist, you may click boxes directly in the GitHub web UI. When entering or editing the entire PR message in the GitHub web UI editor, you may also select a checklist item by adding an x between the brackets: [x]. Spaces and capitalization matter when checking off items this way.

Checklist

  • Tracker (select at least one)
    • References tracker ticket
    • Very recent bug; references commit where it was introduced
    • New feature (ticket optional)
    • Doc update (no ticket needed)
    • Code cleanup (no ticket needed)
  • Component impact
    • Affects Dashboard, opened tracker ticket
    • Affects Orchestrator, opened tracker ticket
    • No impact that needs to be tracked
  • Documentation (select at least one)
    • Updates relevant documentation
    • No doc update is appropriate
  • Tests (select at least one)
Show available Jenkins commands

You must only issue one Jenkins command per-comment. Jenkins does not understand
comments with more than one command.

On OpenSSL 3, OpenSSLDigest::SetFlags(EVP_MD_CTX_FLAG_NON_FIPS_ALLOW)
calls EVP_MD_fetch(NULL, "MD5", "fips=no") every time it runs. rgw
constructs an MD5 digest and calls SetFlags() once per object write to
compute the etag (RGWPutObj::execute and friends), so under small
object workloads this performs a provider-store fetch per request.
EVP_MD_fetch() takes the provider store lock and evaluates the property
query, and OpenSSL documents the intended usage as fetch once, use many
times.

Fetch the algorithm once per process instead and hand each digest
instance its own reference via EVP_MD_up_ref(), keeping the
EVP_MD_free() in the destructor balanced. EVP_MD objects are
reference-counted and safe to share across threads.

Note that using the legacy static EVP_md5() instead would not avoid the
cost: on OpenSSL 3 initializing a digest context from a legacy static
EVP_MD performs an implicit fetch on every EVP_DigestInit_ex(), which
benchmarks the same as the explicit per-instance fetch (see "implicit"
below). Caching the fetched EVP_MD is the only variant that avoids the
per-request provider-store traffic. The other ceph_crypto digests
(SHA1/SHA256/SHA512, used per-request by e.g. rgw's SigV4 code via the
implicit-fetch path) could benefit from the same treatment as a
follow-up.

Microbenchmark (source in the PR description): each iteration performs
one complete etag-style digest, exactly as rgw does per PUT: allocate
an EVP_MD_CTX, obtain the MD5 EVP_MD, EVP_DigestInit_ex(), hash a 4KB
payload, EVP_DigestFinal_ex(), free the context. The three modes
differ only in how the EVP_MD is obtained:

- "fetch": EVP_MD_fetch(NULL, "MD5", "fips=no") per iteration,
  EVP_MD_free() afterwards -- the current behavior, where every digest
  instance performs its own provider-store fetch;
- "cached": EVP_MD_fetch() once into a function-local static, then
  EVP_MD_up_ref() per iteration -- the behavior with this change;
- "implicit": the legacy static EVP_md5() -- no explicit fetch, but on
  OpenSSL 3 EVP_DigestInit_ex() performs an implicit fetch internally
  on every init.

N threads run the loop concurrently and share no state beyond what
libcrypto itself shares. Numbers are wall-clock ns per digest
operation (lower is better), 200K iterations per thread, libcrypto
3.2.4, shown as fetch -> cached:

                          1 thread       8 threads     32 threads
  Ryzen 9 9955HX 16C/32T  3819 -> 3724   567 -> 500    311 -> 197
  Xeon Gold 6152 22C/44T  6506 -> 6344   1131 -> 990   686 -> 583

Reading the numbers: at 1 thread the variants differ by only ~2.5%,
i.e. the uncontended fetch is cheap. The gap widens with concurrency:
+13%/+14% ops/s at 8 threads, +58%/+18% ops/s at 32 threads. That
pattern is the provider store lock serializing concurrent fetches: the
cost is contention rather than per-call latency, so it grows with
exactly the parameter (concurrent requests per daemon) that loaded
radosgw deployments maximize. The "implicit" mode benchmarks the same
as "fetch" on both machines (Ryzen 32T: 314 vs 311 ns/op), confirming
that switching to the legacy EVP_md5() static would not avoid the
contention; caching the fetched EVP_MD is what removes it.

End-to-end behavior is unchanged: s3-tests boto3 functional smoke
passes against a vstart cluster with this change (every PUT's etag is
an MD5 that clients verify). Profiling radosgw under a small vstart
PUT workload shows no measurable throughput difference, as expected:
such a setup is IO-bound and rgw CPU is not the bottleneck there. The
change targets CPU-saturated, many-threaded radosgw deployments.

Signed-off-by: Jusufadis Bakamovic <jusufadis.bakamovic@clyso.com>
@JBakamovic

Copy link
Copy Markdown
Author
// Microbenchmark for the EVP_MD_fetch() caching change in
// common/ceph_crypto.cc (wip-common-crypto-cache-evp-md-fetch).
//
// Simulates rgw's per-PUT etag pattern: one MD5 digest instance per
// request over a 4KB payload. Mode "fetch" reproduces the old behavior
// (EVP_MD_fetch per digest instance, as OpenSSLDigest::SetFlags did);
// mode "cached" reproduces the new behavior (process-wide fetch once +
// EVP_MD_up_ref per instance); mode "implicit" is the FIPS-off baseline
// (EVP_md5() legacy static, no explicit fetch).
//
// Build:
//   g++ -O2 -std=c++17 -pthread bench_evp_md_fetch.cc -lcrypto -o bench_evp_md_fetch
// Run:
//   ./bench_evp_md_fetch <fetch|cached|implicit> <threads> [iters_per_thread]
// Suggested matrix: each mode x threads in {1, 8, 32}, e.g.
//   for m in fetch cached implicit; do for t in 1 8 32; do ./bench_evp_md_fetch $m $t; done; done

#include <openssl/evp.h>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <thread>
#include <vector>

static constexpr size_t PAYLOAD = 4096;

static const EVP_MD* get_cached_md5() {
  static EVP_MD* const md = EVP_MD_fetch(nullptr, "MD5", "fips=no");
  return md;
}

enum class Mode { fetch, cached, implicit };

static void worker(Mode mode, long iters, const unsigned char* payload,
                   unsigned char* sink) {
  unsigned char digest[EVP_MAX_MD_SIZE];
  unsigned int dlen = 0;
  for (long i = 0; i < iters; i++) {
    EVP_MD_CTX* ctx = EVP_MD_CTX_new();          // per request, as rgw does
    EVP_MD* owned = nullptr;
    const EVP_MD* md = nullptr;
    switch (mode) {
    case Mode::fetch:                            // old: fetch per instance
      owned = EVP_MD_fetch(nullptr, "MD5", "fips=no");
      md = owned;
      break;
    case Mode::cached:                           // new: shared fetch + up_ref
      owned = const_cast<EVP_MD*>(get_cached_md5());
      EVP_MD_up_ref(owned);
      md = owned;
      break;
    case Mode::implicit:                         // baseline: legacy static
      md = EVP_md5();
      break;
    }
    EVP_DigestInit_ex(ctx, md, nullptr);
    EVP_DigestUpdate(ctx, payload, PAYLOAD);
    EVP_DigestFinal_ex(ctx, digest, &dlen);
    EVP_MD_CTX_free(ctx);
    if (owned) EVP_MD_free(owned);
    sink[0] ^= digest[0];                        // defeat dead-code elimination
  }
}

int main(int argc, char** argv) {
  if (argc < 3) {
    fprintf(stderr, "usage: %s <fetch|cached|implicit> <threads> [iters]\n", argv[0]);
    return 1;
  }
  Mode mode;
  if (!strcmp(argv[1], "fetch")) mode = Mode::fetch;
  else if (!strcmp(argv[1], "cached")) mode = Mode::cached;
  else if (!strcmp(argv[1], "implicit")) mode = Mode::implicit;
  else { fprintf(stderr, "bad mode\n"); return 1; }

  const int nthreads = atoi(argv[2]);
  const long iters = argc > 3 ? atol(argv[3]) : 200000;

  std::vector<unsigned char> payload(PAYLOAD, 0xab);
  std::vector<unsigned char> sinks(nthreads);

  get_cached_md5();  // warm the cache outside the timed region

  auto t0 = std::chrono::steady_clock::now();
  std::vector<std::thread> threads;
  for (int t = 0; t < nthreads; t++)
    threads.emplace_back(worker, mode, iters, payload.data(), &sinks[t]);
  for (auto& t : threads) t.join();
  auto t1 = std::chrono::steady_clock::now();

  double secs = std::chrono::duration<double>(t1 - t0).count();
  double total = double(iters) * nthreads;
  printf("%-8s threads=%-3d iters/thread=%ld  wall=%.3fs  ops/s=%.0f  ns/op=%.0f\n",
         argv[1], nthreads, iters, secs, total / secs, secs / total * 1e9);
  unsigned char acc = 0; for (auto s : sinks) acc ^= s;
  return acc == 255 ? 2 : 0;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants