Skip to content

Commit 3d8cdcb

Browse files
excelle08meta-codesync[bot]
authored andcommitted
Sample response size from rpc_dist_v2 + bump keepalive default to 200ms
Summary: Two related changes to bring the FeedSim response shape closer to prod multifeed_aggregator's `getStoriesUncompressed` profile. (1) Response-size distribution sampling. `initResponseTemplatePool` and `pickResponseTemplate` previously built 64 templates all at fixed `--num_objects` (= 2000 in our jobs.yml). That produced ~600KB-1MB flat responses, while prod's `getStoriesUncompressed.response_sizes` is p50=173KB, p99=2.7MB, p99.9=6.5MB (15.7x p50→p99 spread). Now when `g_rpc_registry` is loaded (i.e., `--rpc_dist_path` is set), `initResponseTemplatePool`: - Probes once at 64 objects to compute bytes-per-object empirically (handles Silesia + payload map variance per platform); - Draws 64 percentile samples from the loaded `inbound.getStoriesUncompressed.response_sizes` PercentileSampler, sorts them ascending; - Builds one template per target size with `num_objects = target_bytes / bytes_per_object_estimate`; - Stores the actual measured serialized size for each template in `g_response_template_sizes`. `pickResponseTemplate` then samples a `target_bytes` per request and binary-searches the sorted size table for the closest template. Single template lookup is O(log 64) ≈ 6 comparisons; no allocation per request. When `--rpc_dist_path` is unset the code falls back to the legacy round-robin path (jobs.yml `--num_objects`). The pool init MUST happen after `g_rpc_registry = std::move(registry)` so the sampler is loaded; reordered accordingly in main(). (2) `MOCK_KEEPALIVE_INTERVAL_MS` default 100 → 200. Matches the t29/t30 calibration value; 200ms still defeats the cold-channel anti-pattern (t26 showed flat p95 curves at 150ms) while halving the per-channel keepalive bandwidth (~13.3K → 6.7K pings/sec/instance on BGM). Reviewed By: YifanYuan3 Differential Revision: D107586762
1 parent 9c24fd5 commit 3d8cdcb

2 files changed

Lines changed: 158 additions & 22 deletions

File tree

packages/feedsim/run.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -755,8 +755,10 @@ main() {
755755
# Per-MockServicesClient keepalive ping. Each channel issues a tiny
756756
# getStatus() probe every N ms to defeat the cold-channel anti-pattern
757757
# observed at low QPS (BGM saw 14x p95 cliff at q=5 without keepalive).
758-
# Default 100 ms. Set MOCK_KEEPALIVE_INTERVAL_MS=0 to disable.
759-
mock_keepalive_ms="${MOCK_KEEPALIVE_INTERVAL_MS:-100}"
758+
# Default 200 ms (t29/t30 calibration). Set MOCK_KEEPALIVE_INTERVAL_MS=0
759+
# to disable; lower values reduce cold-start latency but add more
760+
# background load on mock_services.
761+
mock_keepalive_ms="${MOCK_KEEPALIVE_INTERVAL_MS:-200}"
760762
if [ "${mock_keepalive_ms}" != "0" ]; then
761763
mock_services_opts="$mock_services_opts --mock_keepalive_interval_ms=${mock_keepalive_ms}"
762764
echo "MockServicesClient keepalive: ENABLED (interval=${mock_keepalive_ms} ms)"

packages/feedsim/third_party/src/workloads/ranking/LeafNodeRank.cc

Lines changed: 154 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -294,26 +294,158 @@ static ranking::RankingResponse generateResponse(int num_objects) {
294294
// destructor chain (was RPC-DataGen=6.3% in the t31v5 profile; this drops
295295
// it to <0.5%).
296296
//
297-
// The 64-slot rotation prevents a single template from getting cache-hot
298-
// enough to skew the Serialization/Compression bucket measurements. The
299-
// queryID mutation is racy across threads but acceptable: we only need
297+
// To match prod's getStoriesUncompressed response_sizes distribution
298+
// (rpc_dist_v2.json: p50=173 KB, p99=2.7 MB, p99.9=6.5 MB), the pool
299+
// holds N templates at quantized percentiles. At request time we sample
300+
// a target_bytes from the inbound.getStoriesUncompressed.response_sizes
301+
// PercentileSampler and return the template whose pre-measured serialized
302+
// size is closest to target_bytes. When --rpc_dist_path is unset the pool
303+
// degrades to a single-size pool keyed off --num_objects (legacy behavior).
304+
//
305+
// The queryID mutation is racy across threads but acceptable: we only need
300306
// wire-shape stability, not value correctness for the benchmark.
301307
static constexpr size_t kResponseTemplatePoolSize = 64;
302308
static std::vector<ranking::RankingResponse> g_response_templates;
309+
// Pre-measured serialized size of each template, in bytes. Used to map a
310+
// target_bytes (sampled from rpc_dist_v2.json) to the nearest template.
311+
static std::vector<size_t> g_response_template_sizes;
303312
static std::atomic<size_t> g_response_template_idx{0};
304313

305-
static void initResponseTemplatePool(int num_objects) {
306-
g_response_templates.reserve(kResponseTemplatePoolSize);
307-
for (size_t i = 0; i < kResponseTemplatePoolSize; ++i) {
308-
g_response_templates.push_back(generateResponse(num_objects));
314+
// Pool of per-thread RNGs for response-size sampling (per-thread to avoid
315+
// shared-state contention; std::mt19937 is not thread-safe).
316+
static thread_local std::mt19937 g_response_size_rng{
317+
std::random_device{}()};
318+
319+
// Helper: measure the serialized size of a RankingResponse via CompactProto.
320+
// Used at pool init to build the (size → template) lookup table.
321+
static size_t measureSerializedSize(const ranking::RankingResponse& resp) {
322+
folly::IOBufQueue queue;
323+
apache::thrift::CompactSerializer::serialize(resp, &queue);
324+
auto buf = queue.move();
325+
return buf ? buf->computeChainDataLength() : 0;
326+
}
327+
328+
// Map an integer obj count to an approximate serialized response size.
329+
// Empirically each RankingObject from the Silesia generator serializes
330+
// to ~300-500 bytes (4 i64 + 3 F14FastMap + list<Action> + double, with
331+
// payloadStrMap drawing strings from Silesia). The mapping is rebuilt
332+
// per-host at pool init by serializing a probe at num_objects=64 and
333+
// dividing by 64, so platform-specific layout doesn't drift the estimate.
334+
static size_t g_bytes_per_object_estimate = 400;
335+
336+
static void initResponseTemplatePool(int default_num_objects) {
337+
// Probe: build one template at a known num_objects, measure its
338+
// serialized size, derive bytes/object. We use 64 objects to amortize
339+
// the per-response fixed overhead (queryID, list header, etc).
340+
constexpr int kProbeObjects = 64;
341+
auto probe = generateResponse(kProbeObjects);
342+
size_t probe_bytes = measureSerializedSize(probe);
343+
if (probe_bytes > 0) {
344+
g_bytes_per_object_estimate =
345+
std::max<size_t>(1, probe_bytes / kProbeObjects);
346+
}
347+
std::cerr << "Response template probe: " << kProbeObjects << " objects → "
348+
<< probe_bytes << " bytes (" << g_bytes_per_object_estimate
349+
<< " B/obj)" << std::endl;
350+
351+
// Choose target sizes for the pool. If the rpc_dist_v2 inbound
352+
// getStoriesUncompressed response_sizes sampler is loaded, draw
353+
// kResponseTemplatePoolSize percentile samples from it so the pool
354+
// covers the full prod distribution shape. Otherwise build a flat
355+
// pool at the legacy --num_objects size.
356+
std::vector<size_t> target_sizes;
357+
target_sizes.reserve(kResponseTemplatePoolSize);
358+
bool use_dist = false;
359+
if (g_rpc_registry != nullptr) {
360+
const auto& sampler = g_rpc_registry->inboundResponseSize(
361+
ranking::InboundIdx::kGetStoriesUncompressed);
362+
if (sampler.isLoaded()) {
363+
use_dist = true;
364+
// Deterministic-but-spread sampling: walk equally-spaced percentiles
365+
// across [0, 1). Avoids RNG dependency at init time.
366+
std::mt19937 init_rng(/*seed=*/0xfeed51);
367+
for (size_t i = 0; i < kResponseTemplatePoolSize; ++i) {
368+
target_sizes.push_back(
369+
std::max<size_t>(1, static_cast<size_t>(sampler.sample(init_rng))));
370+
}
371+
}
309372
}
373+
if (!use_dist) {
374+
size_t default_size = static_cast<size_t>(default_num_objects) *
375+
g_bytes_per_object_estimate;
376+
for (size_t i = 0; i < kResponseTemplatePoolSize; ++i) {
377+
target_sizes.push_back(default_size);
378+
}
379+
}
380+
// Sort target_sizes so g_response_template_sizes is ascending; lets
381+
// pickResponseTemplate binary-search for the nearest match.
382+
std::sort(target_sizes.begin(), target_sizes.end());
383+
384+
g_response_templates.reserve(kResponseTemplatePoolSize);
385+
g_response_template_sizes.reserve(kResponseTemplatePoolSize);
386+
size_t min_built = SIZE_MAX, max_built = 0, sum_built = 0;
387+
for (size_t target : target_sizes) {
388+
int n_obj = std::max<int>(
389+
1, static_cast<int>(target / g_bytes_per_object_estimate));
390+
auto resp = generateResponse(n_obj);
391+
size_t actual = measureSerializedSize(resp);
392+
g_response_templates.push_back(std::move(resp));
393+
g_response_template_sizes.push_back(actual);
394+
min_built = std::min(min_built, actual);
395+
max_built = std::max(max_built, actual);
396+
sum_built += actual;
397+
}
398+
std::cerr << "Response template pool: " << kResponseTemplatePoolSize
399+
<< " templates built from "
400+
<< (use_dist
401+
? "rpc_dist_v2.inbound.getStoriesUncompressed.response_sizes"
402+
: "fixed --num_objects")
403+
<< "; serialized bytes min=" << min_built << " mean="
404+
<< (sum_built / kResponseTemplatePoolSize) << " max=" << max_built
405+
<< std::endl;
310406
}
311407

408+
// Picks a response template whose serialized size is closest to a sample
409+
// drawn from the rpc_dist_v2 response_sizes distribution. Falls back to
410+
// round-robin when the registry is unset (legacy behavior).
312411
static ranking::RankingResponse& pickResponseTemplate() {
313-
size_t idx = g_response_template_idx.fetch_add(
314-
1, std::memory_order_relaxed) % kResponseTemplatePoolSize;
315-
ranking::RankingResponse& resp = g_response_templates[idx];
316-
resp.queryID() = static_cast<int64_t>(idx);
412+
size_t pool_idx;
413+
if (g_rpc_registry != nullptr) {
414+
const auto& sampler = g_rpc_registry->inboundResponseSize(
415+
ranking::InboundIdx::kGetStoriesUncompressed);
416+
if (sampler.isLoaded()) {
417+
size_t target =
418+
static_cast<size_t>(sampler.sample(g_response_size_rng));
419+
// Binary search for the template with serialized size closest to
420+
// target. g_response_template_sizes is sorted ascending.
421+
auto it = std::lower_bound(
422+
g_response_template_sizes.begin(),
423+
g_response_template_sizes.end(),
424+
target);
425+
if (it == g_response_template_sizes.end()) {
426+
pool_idx = g_response_template_sizes.size() - 1;
427+
} else if (it == g_response_template_sizes.begin()) {
428+
pool_idx = 0;
429+
} else {
430+
// Pick the closer of (*it) or (*(it - 1))
431+
size_t hi_diff = *it - target;
432+
size_t lo_diff = target - *(it - 1);
433+
pool_idx = (lo_diff < hi_diff)
434+
? static_cast<size_t>((it - 1) - g_response_template_sizes.begin())
435+
: static_cast<size_t>(it - g_response_template_sizes.begin());
436+
}
437+
} else {
438+
pool_idx = g_response_template_idx.fetch_add(
439+
1, std::memory_order_relaxed) %
440+
kResponseTemplatePoolSize;
441+
}
442+
} else {
443+
pool_idx = g_response_template_idx.fetch_add(
444+
1, std::memory_order_relaxed) %
445+
kResponseTemplatePoolSize;
446+
}
447+
ranking::RankingResponse& resp = g_response_templates[pool_idx];
448+
resp.queryID() = static_cast<int64_t>(pool_idx);
317449
return resp;
318450
}
319451

@@ -2411,15 +2543,6 @@ int main(int argc, char** argv) {
24112543
<< std::endl;
24122544
}
24132545

2414-
// Pre-build the response template pool now that generateResponse is wired
2415-
// (either Silesia or xor128). Each handler grabs a template by index +
2416-
// mutates queryID, eliminating the per-request construct+destruct chain
2417-
// that was 6.3% of total CPU (RPC-DataGen) in the t31v5 profile.
2418-
initResponseTemplatePool(args.num_objects_arg);
2419-
std::cout << "Response template pool: " << kResponseTemplatePoolSize
2420-
<< " pre-built " << args.num_objects_arg
2421-
<< "-object RankingResponse instances" << std::endl;
2422-
24232546
// Phase 5: load rpc_dist.json and instantiate the RpcDistRegistry. When
24242547
// --rpc_dist_path is empty (default) OR --use_legacy_sleep is set, the
24252548
// legacy folly::futures::sleep I/O simulation is used everywhere --
@@ -2434,6 +2557,9 @@ int main(int argc, char** argv) {
24342557
// every ThreadStartup skip MockServicesClient construction (gate at
24352558
// `rpc_registry != nullptr`) and simulateIoOrFanout fall through to
24362559
// folly::futures::sleep (gate at `mock_client != nullptr`).
2560+
//
2561+
// NOTE: registry is loaded BEFORE initResponseTemplatePool so the
2562+
// response-size distribution sampler can shape the template pool.
24372563
if (args.use_legacy_sleep_flag) {
24382564
std::cout << "RPC fanout: disabled (--use_legacy_sleep override);"
24392565
<< " using legacy folly::futures::sleep" << std::endl;
@@ -2459,6 +2585,14 @@ int main(int argc, char** argv) {
24592585
<< " folly::futures::sleep" << std::endl;
24602586
}
24612587

2588+
// Pre-build the response template pool now that generateResponse is wired
2589+
// (either Silesia or xor128) AND g_rpc_registry is loaded (so the pool
2590+
// can shape itself to the response_sizes distribution). Each handler
2591+
// grabs a template by index + mutates queryID, eliminating the
2592+
// per-request construct+destruct chain that was 6.3% of total CPU
2593+
// (RPC-DataGen) in the t31v5 profile.
2594+
initResponseTemplatePool(args.num_objects_arg);
2595+
24622596
int fake_argc = 1;
24632597
char* fake_argv[2] = {const_cast<char*>("./LeafNodeRank"), nullptr};
24642598
char** sargv = static_cast<char**>(fake_argv);

0 commit comments

Comments
 (0)