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 */