@cryptotaxi247 / netdata-1 / commits / d4da8d90a

Add dictionary benchmark (#22041)

* Add dictionary benchmarking framework with `dicttest-benchmark` support - Implemented a benchmarking utility for testing dictionary performance under various workloads (traversal, lookup, mixed read/write). - Added support for latency tracking, statistical aggregation, and percentile calculations. - Integrated configurable concurrent benchmarking with reader/writer threads. - Enabled `dicttest-benchmark` option in unit test infrastructure. * Enhance latency tracking in dictionary benchmarks with reservoir sampling and dynamic memory allocation for samples. * Address review comments

Stelios Fragkakis committed Mar 26, 2026 at 09:13 UTC d4da8d90a93466913a1c6cf8c2f611a0e47d2fbb
3 files changed +449
src/daemon/main.c
+4
@@ -469,6 +469,10 @@ int netdata_main(int argc, char **argv) {
469 unittest_running = true;
470 return dictionary_unittest(10000);
471 }
472 + else if(strcmp(optarg, "dicttest-benchmark") == 0) {
473 + unittest_running = true;
474 + return dictionary_unittest_benchmark();
475 + }
476 else if(strcmp(optarg, "araltest") == 0) {
477 unittest_running = true;
478 return aral_unittest(10000);
src/libnetdata/dictionary/dictionary-unittest.c
+444
@@ -1033,6 +1033,450 @@ bool dictionary_traverse_or_destroy_unittest(void) {
1033 * sanitizer. Need to investigate if it's introduced by the unit-test itself,
1034 * or the dictionary implementation.
1035 */
1036 +// ============================================================================
1037 +// Dictionary benchmark harness for before/after concurrency work (for example
1038 +// RCU internals). Keep the workloads stable and the output compact.
1039 +// ============================================================================
1040 +
1041 +#define DICT_BENCH_MAX_SAMPLES 4096
1042 +#define DICT_BENCH_SAMPLE_EVERY 64
1043 +
1044 +typedef enum {
1045 + DICT_BENCH_READ_NONE = 0,
1046 + DICT_BENCH_READ_TRAVERSAL,
1047 + DICT_BENCH_READ_LOOKUP_HOT,
1048 + DICT_BENCH_READ_LOOKUP_RANDOM,
1049 +} dict_bench_read_mode_t;
1050 +
1051 +typedef enum {
1052 + DICT_BENCH_WRITE_NONE = 0,
1053 + DICT_BENCH_WRITE_CHURN,
1054 + DICT_BENCH_WRITE_UPDATE,
1055 +} dict_bench_write_mode_t;
1056 +
1057 +struct dict_bench_config {
1058 + const char *workload;
1059 + size_t entries;
1060 + int readers;
1061 + int writers;
1062 + time_t seconds_to_run;
1063 + dict_bench_read_mode_t read_mode;
1064 + dict_bench_write_mode_t write_mode;
1065 +};
1066 +
1067 +struct dict_bench_stats {
1068 + uint64_t ops;
1069 + uint64_t items_seen;
1070 + uint64_t latency_total_ut;
1071 + size_t latency_samples_used;
1072 + uint64_t latency_samples_seen;
1073 + usec_t latency_samples[DICT_BENCH_MAX_SAMPLES];
1074 +};
1075 +
1076 +struct dict_bench_thread {
1077 + int id;
1078 + int *join;
1079 + DICTIONARY *dict;
1080 + const struct dict_bench_config *cfg;
1081 + struct dict_bench_stats stats;
1082 + bool is_writer;
1083 + uint32_t rng_state;
1084 + ND_THREAD *thread;
1085 +};
1086 +
1087 +struct dict_bench_summary {
1088 + uint64_t read_ops;
1089 + uint64_t read_items_seen;
1090 + uint64_t write_ops;
1091 + double read_avg_ut;
1092 + double read_p99_ut;
1093 + double write_avg_ut;
1094 + double write_p99_ut;
1095 +};
1096 +
1097 +static int dict_bench_usec_cmp(const void *a, const void *b) {
1098 + const usec_t ua = *(const usec_t *)a;
1099 + const usec_t ub = *(const usec_t *)b;
1100 + return (ua > ub) - (ua < ub);
1101 +}
1102 +
1103 +static inline uint32_t dict_bench_rand(uint32_t *state) {
1104 + if(!*state) *state = 1;
1105 + *state ^= *state << 13;
1106 + *state ^= *state >> 17;
1107 + *state ^= *state << 5;
1108 + return *state;
1109 +}
1110 +
1111 +static inline void dict_bench_record_latency(struct dict_bench_stats *stats, usec_t latency_ut, uint32_t *rng) {
1112 + stats->latency_total_ut += latency_ut;
1113 + stats->latency_samples_seen++;
1114 +
1115 + if(stats->latency_samples_used < DICT_BENCH_MAX_SAMPLES)
1116 + stats->latency_samples[stats->latency_samples_used++] = latency_ut;
1117 + else {
1118 + // reservoir sampling: replace a random slot with probability N/total_seen
1119 + uint32_t idx = dict_bench_rand(rng) % stats->latency_samples_seen;
1120 + if(idx < DICT_BENCH_MAX_SAMPLES)
1121 + stats->latency_samples[idx] = latency_ut;
1122 + }
1123 +}
1124 +
1125 +static void dict_bench_lookup_key(char *buf, size_t len, size_t key_idx) {
1126 + snprintfz(buf, len, "bench-item-%zu", key_idx);
1127 +}
1128 +
1129 +static void dict_bench_reader_thread(void *arg) {
1130 + struct dict_bench_thread *ctx = arg;
1131 + const size_t hot_keys = MIN(ctx->cfg->entries, (size_t)64);
1132 +
1133 + while(!__atomic_load_n(ctx->join, __ATOMIC_RELAXED)) {
1134 + usec_t started_ut = 0;
1135 + bool sample_latency = ((ctx->stats.ops & (DICT_BENCH_SAMPLE_EVERY - 1)) == 0);
1136 +
1137 + if(sample_latency)
1138 + started_ut = now_monotonic_usec();
1139 +
1140 + if(ctx->cfg->read_mode == DICT_BENCH_READ_TRAVERSAL) {
1141 + void *v;
1142 + dfe_start_read(ctx->dict, v) {
1143 + (void)v;
1144 + ctx->stats.items_seen++;
1145 + }
1146 + dfe_done(v);
1147 + }
1148 + else {
1149 + char name[64];
1150 + size_t key_idx;
1151 +
1152 + if(ctx->cfg->read_mode == DICT_BENCH_READ_LOOKUP_HOT)
1153 + key_idx = dict_bench_rand(&ctx->rng_state) % hot_keys;
1154 + else
1155 + key_idx = dict_bench_rand(&ctx->rng_state) % ctx->cfg->entries;
1156 +
1157 + dict_bench_lookup_key(name, sizeof(name), key_idx);
1158 + (void)dictionary_get(ctx->dict, name);
1159 + }
1160 +
1161 + ctx->stats.ops++;
1162 +
1163 + if(sample_latency)
1164 + dict_bench_record_latency(&ctx->stats, now_monotonic_usec() - started_ut, &ctx->rng_state);
1165 + }
1166 +}
1167 +
1168 +static void dict_bench_writer_thread(void *arg) {
1169 + struct dict_bench_thread *ctx = arg;
1170 + uint64_t counter = 0;
1171 +
1172 + while(!__atomic_load_n(ctx->join, __ATOMIC_RELAXED)) {
1173 + usec_t started_ut = 0;
1174 + bool sample_latency = ((ctx->stats.ops & (DICT_BENCH_SAMPLE_EVERY - 1)) == 0);
1175 +
1176 + if(sample_latency)
1177 + started_ut = now_monotonic_usec();
1178 +
1179 + if(ctx->cfg->write_mode == DICT_BENCH_WRITE_CHURN) {
1180 + char buf[64];
1181 + snprintfz(buf, sizeof(buf), "writer-key-%d-%"PRIu64, ctx->id, counter);
1182 + dictionary_set(ctx->dict, buf, NULL, 0);
1183 + dictionary_del(ctx->dict, buf);
1184 + }
1185 + else {
1186 + char buf[64];
1187 + size_t key_idx = dict_bench_rand(&ctx->rng_state) % ctx->cfg->entries;
1188 + uint64_t value = counter;
1189 +
1190 + dict_bench_lookup_key(buf, sizeof(buf), key_idx);
1191 + dictionary_set(ctx->dict, buf, &value, sizeof(value));
1192 + }
1193 +
1194 + counter++;
1195 + ctx->stats.ops++;
1196 +
1197 + if(sample_latency)
1198 + dict_bench_record_latency(&ctx->stats, now_monotonic_usec() - started_ut, &ctx->rng_state);
1199 + }
1200 +}
1201 +
1202 +static void dict_bench_prepopulate(DICTIONARY *dict, size_t entries) {
1203 + char name[64];
1204 +
1205 + for(size_t i = 0; i < entries; i++) {
1206 + uint64_t value = i;
1207 + dict_bench_lookup_key(name, sizeof(name), i);
1208 + dictionary_set(dict, name, &value, sizeof(value));
1209 + }
1210 +}
1211 +
1212 +static double dict_bench_percentile_ut(usec_t *samples, size_t samples_used, size_t percentile) {
1213 + if(!samples_used)
1214 + return 0.0;
1215 +
1216 + qsort(samples, samples_used, sizeof(*samples), dict_bench_usec_cmp);
1217 +
1218 + size_t idx = ((samples_used - 1) * percentile) / 100;
1219 + return (double)samples[idx];
1220 +}
1221 +
1222 +static void dict_bench_aggregate_latency(
1223 + struct dict_bench_thread *threads,
1224 + int count,
1225 + bool writers,
1226 + double *avg_ut,
1227 + double *p99_ut
1228 +) {
1229 + size_t max_samples = (size_t)DICT_BENCH_MAX_SAMPLES * count;
1230 + usec_t *samples = callocz(max_samples, sizeof(usec_t));
1231 + size_t samples_used = 0;
1232 + uint64_t total_latency_ut = 0;
1233 + uint64_t total_ops = 0;
1234 +
1235 + for(int i = 0; i < count; i++) {
1236 + if(threads[i].is_writer != writers)
1237 + continue;
1238 +
1239 + total_latency_ut += threads[i].stats.latency_total_ut;
1240 + total_ops += threads[i].stats.latency_samples_seen;
1241 +
1242 + size_t available = max_samples - samples_used;
1243 + size_t copy = MIN(available, threads[i].stats.latency_samples_used);
1244 + if(copy) {
1245 + memcpy(&samples[samples_used], threads[i].stats.latency_samples, copy * sizeof(usec_t));
1246 + samples_used += copy;
1247 + }
1248 + }
1249 +
1250 + *avg_ut = total_ops ? (double)total_latency_ut / (double)total_ops : 0.0;
1251 + *p99_ut = dict_bench_percentile_ut(samples, samples_used, 99);
1252 + freez(samples);
1253 +}
1254 +
1255 +static void dict_bench_run_case(const struct dict_bench_config *cfg) {
1256 + int total_threads = cfg->readers + cfg->writers;
1257 + int join = 0;
1258 + struct dict_bench_thread *threads = callocz(total_threads, sizeof(*threads));
1259 + struct dict_bench_summary summary = {0};
1260 + DICTIONARY *dict = dictionary_create(DICT_OPTION_NONE);
1261 + dict_bench_prepopulate(dict, cfg->entries);
1262 +
1263 + for(int i = 0; i < cfg->readers; i++) {
1264 + char tname[32];
1265 + threads[i] = (struct dict_bench_thread){
1266 + .id = i,
1267 + .join = &join,
1268 + .dict = dict,
1269 + .cfg = cfg,
1270 + .is_writer = false,
1271 + .rng_state = (uint32_t)(i + 1) * 2654435761U,
1272 + };
1273 + snprintfz(tname, sizeof(tname), "dbread%d", i);
1274 + threads[i].thread = nd_thread_create(tname, NETDATA_THREAD_OPTION_DONT_LOG,
1275 + dict_bench_reader_thread, &threads[i]);
1276 + }
1277 +
1278 + for(int i = 0; i < cfg->writers; i++) {
1279 + int idx = cfg->readers + i;
1280 + char tname[32];
1281 + threads[idx] = (struct dict_bench_thread){
1282 + .id = idx,
1283 + .join = &join,
1284 + .dict = dict,
1285 + .cfg = cfg,
1286 + .is_writer = true,
1287 + .rng_state = (uint32_t)(idx + 1) * 2246822519U,
1288 + };
1289 + snprintfz(tname, sizeof(tname), "dbwrite%d", i);
1290 + threads[idx].thread = nd_thread_create(tname, NETDATA_THREAD_OPTION_DONT_LOG,
1291 + dict_bench_writer_thread, &threads[idx]);
1292 + }
1293 +
1294 + sleep_usec(cfg->seconds_to_run * USEC_PER_SEC);
1295 + __atomic_store_n(&join, 1, __ATOMIC_RELAXED);
1296 +
1297 + for(int i = 0; i < total_threads; i++) {
1298 + nd_thread_join(threads[i].thread);
1299 + if(threads[i].is_writer)
1300 + summary.write_ops += threads[i].stats.ops;
1301 + else {
1302 + summary.read_ops += threads[i].stats.ops;
1303 + summary.read_items_seen += threads[i].stats.items_seen;
1304 + }
1305 + }
1306 +
1307 + dict_bench_aggregate_latency(threads, total_threads, false, &summary.read_avg_ut, &summary.read_p99_ut);
1308 + dict_bench_aggregate_latency(threads, total_threads, true, &summary.write_avg_ut, &summary.write_p99_ut);
1309 + dictionary_destroy(dict);
1310 + cleanup_destroyed_dictionaries(false);
1311 + freez(threads);
1312 +
1313 + if(cfg->read_mode == DICT_BENCH_READ_TRAVERSAL) {
1314 + fprintf(stderr, "%-14s %8zu %8d %8d %14.0f %14.0f %14.0f %14.2f %14.2f %14.2f %14.2f\n",
1315 + cfg->workload,
1316 + cfg->entries,
1317 + cfg->readers,
1318 + cfg->writers,
1319 + cfg->seconds_to_run ? (double)summary.read_ops / cfg->seconds_to_run : 0.0,
1320 + cfg->seconds_to_run ? (double)summary.read_items_seen / cfg->seconds_to_run : 0.0,
1321 + cfg->seconds_to_run ? (double)summary.write_ops / cfg->seconds_to_run : 0.0,
1322 + summary.read_avg_ut,
1323 + summary.read_p99_ut,
1324 + summary.write_avg_ut,
1325 + summary.write_p99_ut);
1326 + }
1327 + else {
1328 + fprintf(stderr, "%-14s %8zu %8d %8d %14.0f %14.0f %14.2f %14.2f %14.2f %14.2f\n",
1329 + cfg->workload,
1330 + cfg->entries,
1331 + cfg->readers,
1332 + cfg->writers,
1333 + cfg->seconds_to_run ? (double)summary.read_ops / cfg->seconds_to_run : 0.0,
1334 + cfg->seconds_to_run ? (double)summary.write_ops / cfg->seconds_to_run : 0.0,
1335 + summary.read_avg_ut,
1336 + summary.read_p99_ut,
1337 + summary.write_avg_ut,
1338 + summary.write_p99_ut);
1339 + }
1340 +}
1341 +
1342 +static void dict_bench_print_separator(size_t width) {
1343 + for(size_t i = 0; i < width; i++)
1344 + fputc('-', stderr);
1345 + fputc('\n', stderr);
1346 +}
1347 +
1348 +static void dict_bench_print_header_line(const char *line) {
1349 + fprintf(stderr, "%s\n", line);
1350 + dict_bench_print_separator(strlen(line));
1351 +}
1352 +
1353 +static void dict_bench_print_suite_header(
1354 + const char *suite,
1355 + const char *read_ops_label,
1356 + const char *read_avg_label,
1357 + const char *read_slow_label,
1358 + const char *writer_desc
1359 +) {
1360 + char header[512];
1361 +
1362 + fprintf(stderr, "\n=== %s ===\n", suite);
1363 + fprintf(stderr, "%s\n", writer_desc);
1364 + snprintfz(header, sizeof(header),
1365 + "%-14s %8s %8s %8s %14s %14s %14s %14s %14s %14s",
1366 + "workload", "entries", "readers", "writers",
1367 + read_ops_label, "write ops/s",
1368 + read_avg_label, read_slow_label, "w avg us", "w slow us");
1369 + dict_bench_print_header_line(header);
1370 +}
1371 +
1372 +static void dict_bench_print_traversal_header(void) {
1373 + char header[512];
1374 +
1375 + fprintf(stderr, "\n=== Dictionary Traversal Benchmark ===\n");
1376 + fprintf(stderr, "Reader workload: full dictionary scan. Writer workload: temporary-key insert followed by delete.\n");
1377 + snprintfz(header, sizeof(header),
1378 + "%-14s %8s %8s %8s %14s %14s %14s %14s %14s %14s %14s",
1379 + "workload", "entries", "readers", "writers",
1380 + "full scans/s", "items visited/s", "write ops/s",
1381 + "avg scan us", "slow scan us", "w avg us", "w slow us");
1382 + dict_bench_print_header_line(header);
1383 +}
1384 +
1385 +int dictionary_unittest_benchmark(void) {
1386 + const time_t seconds_to_run = 2;
1387 + const size_t sizes[] = {100, 10000};
1388 + const int readers[] = {1, 4, 8};
1389 + const int writers[] = {0, 1, 2};
1390 +
1391 + dict_bench_print_traversal_header();
1392 + for(size_t i = 0; i < sizeof(readers) / sizeof(readers[0]); i++) {
1393 + for(size_t j = 0; j < sizeof(writers) / sizeof(writers[0]); j++) {
1394 + struct dict_bench_config cfg = {
1395 + .workload = "traversal",
1396 + .entries = 10000,
1397 + .readers = readers[i],
1398 + .writers = writers[j],
1399 + .seconds_to_run = seconds_to_run,
1400 + .read_mode = DICT_BENCH_READ_TRAVERSAL,
1401 + .write_mode = DICT_BENCH_WRITE_CHURN,
1402 + };
1403 + dict_bench_run_case(&cfg);
1404 + }
1405 + }
1406 +
1407 + dict_bench_print_suite_header(
1408 + "Dictionary Lookup Benchmark",
1409 + "lookups/s",
1410 + "avg lookup us",
1411 + "slow lookup us",
1412 + "Reader workload: dictionary_get(). Writer workload: overwrite an existing dictionary entry."
1413 + );
1414 + for(size_t s = 0; s < sizeof(sizes) / sizeof(sizes[0]); s++) {
1415 + for(size_t i = 0; i < sizeof(readers) / sizeof(readers[0]); i++) {
1416 + for(size_t j = 0; j < sizeof(writers) / sizeof(writers[0]); j++) {
1417 + struct dict_bench_config hot_cfg = {
1418 + .workload = "lookup-hot",
1419 + .entries = sizes[s],
1420 + .readers = readers[i],
1421 + .writers = writers[j],
1422 + .seconds_to_run = seconds_to_run,
1423 + .read_mode = DICT_BENCH_READ_LOOKUP_HOT,
1424 + .write_mode = DICT_BENCH_WRITE_UPDATE,
1425 + };
1426 + struct dict_bench_config random_cfg = hot_cfg;
1427 + random_cfg.workload = "lookup-random";
1428 + random_cfg.read_mode = DICT_BENCH_READ_LOOKUP_RANDOM;
1429 +
1430 + dict_bench_run_case(&hot_cfg);
1431 + dict_bench_run_case(&random_cfg);
1432 + }
1433 + }
1434 + }
1435 +
1436 + dict_bench_print_suite_header(
1437 + "Dictionary Mixed RW Benchmark",
1438 + "read ops/s",
1439 + "read avg us",
1440 + "slow read us",
1441 + "Reader workload: random lookups. Writer workload depends on the row: update rewrites existing keys, churn inserts then deletes temporary keys."
1442 + );
1443 + {
1444 + const struct dict_bench_config configs[] = {
1445 + {.workload = "mixed-8r1w", .entries = 10000, .readers = 8, .writers = 1, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_LOOKUP_RANDOM, .write_mode = DICT_BENCH_WRITE_UPDATE},
1446 + {.workload = "mixed-8r2w", .entries = 10000, .readers = 8, .writers = 2, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_LOOKUP_RANDOM, .write_mode = DICT_BENCH_WRITE_UPDATE},
1447 + {.workload = "mixed-4r1w", .entries = 10000, .readers = 4, .writers = 1, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_LOOKUP_RANDOM, .write_mode = DICT_BENCH_WRITE_CHURN},
1448 + {.workload = "mixed-4r2w", .entries = 10000, .readers = 4, .writers = 2, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_LOOKUP_RANDOM, .write_mode = DICT_BENCH_WRITE_CHURN},
1449 + };
1450 +
1451 + for(size_t i = 0; i < sizeof(configs) / sizeof(configs[0]); i++)
1452 + dict_bench_run_case(&configs[i]);
1453 + }
1454 +
1455 + dict_bench_print_suite_header(
1456 + "Dictionary Writer Cost Benchmark",
1457 + "read ops/s",
1458 + "read avg us",
1459 + "slow read us",
1460 + "Writer workload: 'update' overwrites existing keys, 'churn' inserts then deletes temporary keys; '+r' rows include background readers."
1461 + );
1462 + {
1463 + const struct dict_bench_config configs[] = {
1464 + {.workload = "update-1w", .entries = 10000, .readers = 0, .writers = 1, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_NONE, .write_mode = DICT_BENCH_WRITE_UPDATE},
1465 + {.workload = "update-2w", .entries = 10000, .readers = 0, .writers = 2, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_NONE, .write_mode = DICT_BENCH_WRITE_UPDATE},
1466 + {.workload = "churn-1w", .entries = 10000, .readers = 0, .writers = 1, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_NONE, .write_mode = DICT_BENCH_WRITE_CHURN},
1467 + {.workload = "churn-2w", .entries = 10000, .readers = 0, .writers = 2, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_NONE, .write_mode = DICT_BENCH_WRITE_CHURN},
1468 + {.workload = "update+r", .entries = 10000, .readers = 8, .writers = 1, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_LOOKUP_RANDOM, .write_mode = DICT_BENCH_WRITE_UPDATE},
1469 + {.workload = "churn+r", .entries = 10000, .readers = 8, .writers = 2, .seconds_to_run = seconds_to_run, .read_mode = DICT_BENCH_READ_LOOKUP_RANDOM, .write_mode = DICT_BENCH_WRITE_CHURN},
1470 + };
1471 +
1472 + for(size_t i = 0; i < sizeof(configs) / sizeof(configs[0]); i++)
1473 + dict_bench_run_case(&configs[i]);
1474 + }
1475 +
1476 + fprintf(stderr, "\n");
1477 + return 0;
1478 +}
1479 +
1480 int dictionary_unittest(size_t entries) {
1481 if(entries < 10) entries = 10;
1482
src/libnetdata/dictionary/dictionary.h
+1
@@ -329,5 +329,6 @@ size_t dictionary_referenced_items(DICTIONARY *dict);
329 extern struct dictionary_stats dictionary_stats_category_other;
330
331 int dictionary_unittest(size_t entries);
332 +int dictionary_unittest_benchmark(void);
333
334 #endif /* NETDATA_DICTIONARY_H */