-
Type:
Improvement
-
Resolution: Unresolved
-
Priority:
Major - P3
-
None
-
Affects Version/s: None
-
Component/s: Configuration
-
None
-
Storage Engines - Foundations
-
231.473
-
None
-
None
Issue Summary
_wt_config_collapse is quadratic in the configuration size: for every key of the default (first) config string it calls wti_config_get, which re-scans every string in the stack — and _config_getraw always scans each string to the end, because it needs the last match. For the ~50-key file_meta base used on every file creation, that is ~50 full re-parses of a ~1 KB string per table. Rewrite the function to parse every string exactly once into a last-wins key table, then emit in cfg[0] order. Measured: 65.2 -> 5.4 us/call (12x) on the file-create config stack. Output is byte-for-byte identical to the current implementation, enforced by a fuzz test, so there is no on-disk format impact.
Context
- Found during the BF-44421 investigation (disaggregated-storage checkpoint pick-up creating 50,029 ingest tables at follower startup): config assembly in __create_file was 13,669 ms of an 18.1 s pick-up (75.4%, 273 us/table). The collapse half of that is ~62% based on local measurement; the strip half is WT-18513.
- Local micro-benchmark replaying the exact __create_file cfg stack (file_meta base + ingest template + id/version string, Release build, 20k iterations, best of 3): collapse 65.2 us/call before, 5.4 us/call after.
- A sampling profile shows ~98% of collapse time inside _wti_config_get/_config_getraw — i.e. re-parsing, not allocation or formatting.
- The path is shared: every file create, checkpoint-metadata update, alter, and import goes through collapse, so the fix benefits all workloads, not just disagg pick-up. Related: WT-18513 (strip skip),
WT-18174(removed collapse from the checkpoint update path), BF-44421.
Proposed change
diff --git a/src/config/config_collapse.c b/src/config/config_collapse.c index 0305a25b41..cb3caa822c 100644 --- a/src/config/config_collapse.c +++ b/src/config/config_collapse.c @@ -1,12 +1,51 @@ int __wt_config_collapse(WT_SESSION_IMPL *session, const char **cfg, char **config_ret) { + /* + * The last-seen key/value pair for each key, across all of the configuration strings. The items + * reference the original strings, which outlive this function. + */ + struct __wt_config_collapse_override { + WT_CONFIG_ITEM k, v; + } *overrides; WT_CONFIG cparser; WT_CONFIG_ITEM k, v; WT_DECL_ITEM(tmp); WT_DECL_RET; + size_t i, overrides_allocated, overrides_next; + const char **c; + char *p; *config_ret = NULL; + + overrides = NULL; + overrides_allocated = overrides_next = 0; + + /* + * Parse every string once, keeping the last value for every key. This matches + * __wti_config_get, which finds the last match within the last string containing the key -- + * including repeated keys within the first string. Parsing every string once up front avoids + * re-scanning every string for every key of the first, default string, which is quadratic in + * the configuration size. + */ + for (c = cfg; *c != NULL; ++c) { + __wt_config_init(session, &cparser, *c); + while ((ret = __wt_config_next(&cparser, &k, &v)) == 0) { + if (k.type != WT_CONFIG_ITEM_STRING && k.type != WT_CONFIG_ITEM_ID) + continue; + for (i = 0; i < overrides_next; ++i) + if (overrides[i].k.len == k.len && strncmp(overrides[i].k.str, k.str, k.len) == 0) + break; + if (i == overrides_next) { + WT_ERR( + __wt_realloc_def(session, &overrides_allocated, overrides_next + 1, &overrides)); + ++overrides_next; + } + overrides[i].k = k; + overrides[i].v = v; + } + WT_ERR_NOTFOUND_OK(ret, false); + } WT_RET(__wt_scr_alloc(session, 1024, &tmp)); @@ -14,13 +53,32 @@ while ((ret = __wt_config_next(&cparser, &k, &v)) == 0) { if (k.type != WT_CONFIG_ITEM_STRING && k.type != WT_CONFIG_ITEM_ID) WT_ERR_MSG(session, EINVAL, "Invalid configuration key found: '%s'", k.str); - WT_ERR(__wti_config_get(session, cfg, &k, &v)); + /* + * Dotted keys require descending into nested structures; take the slow path for them. Every + * other key of the first string is in the table. + */ + if (memchr(k.str, '.', k.len) != NULL) + WT_ERR(__wti_config_get(session, cfg, &k, &v)); + else + for (i = 0; i < overrides_next; ++i) + if (overrides[i].k.len == k.len && strncmp(overrides[i].k.str, k.str, k.len) == 0) { + v = overrides[i].v; + break; + } /* Include the quotes around string keys/values. */ if (k.type == WT_CONFIG_ITEM_STRING) WT_CONFIG_PRESERVE_QUOTES(session, &k); if (v.type == WT_CONFIG_ITEM_STRING) WT_CONFIG_PRESERVE_QUOTES(session, &v); - WT_ERR(__wt_buf_catfmt(session, tmp, "%.*s=%.*s,", (int)k.len, k.str, (int)v.len, v.str)); + WT_ERR(__wt_buf_extend(session, tmp, tmp->size + k.len + v.len + 2)); + p = (char *)tmp->data + tmp->size; + memcpy(p, k.str, k.len); + p += k.len; + *p++ = '='; + memcpy(p, v.str, v.len); + p += v.len; + *p++ = ','; + tmp->size = (size_t)(p - (char *)tmp->data); } /* We loop until error, and the expected error is WT_NOTFOUND. */ @@ -38,6 +96,7 @@ ret = __wt_strndup(session, tmp->data, tmp->size, config_ret); err: + __wt_free(session, overrides); __wt_scr_free(session, &tmp); return (ret); }
Three independent pieces, all required for the win:
- Last-wins table: every string parsed once; the value for a key is the last occurrence in the last string containing it, which is exactly __wti_config_get's semantics (it searches strings in reverse and takes the last match within the winning string).
- cfg[0] is part of the table: required for correctness — with duplicate keys in cfg[0] itself, every emitted occurrence must resolve to the last value anywhere, including later occurrences in cfg[0]. (The fuzzer caught this; see below.)
- memcpy emit: replaces per-key vsnprintf (__wt_buf_catfmt) with a single buffer extension plus two memcpys; the table items reference the original strings, so no copies are made until the final output.
Why the output is provably identical
The function's contract is its output string; the change is algorithmic only. Equivalence is enforced by test, not by inspection:
- The catch2 harness (test/catch2/misc_tests/test_config_collapse_perf.cpp, tag [collapse_perf]) keeps the previous implementation verbatim as a reference and requires byte-for-byte equality over 2,000 randomized cfg stacks: 1-3 overriding strings, duplicate keys within and across strings, keys absent from cfg[0] (must be discarded), nested structs (replaced wholesale, never merged), quoted strings, booleans, numerics, empty values. All pass, Debug and Release.
- The fuzzer caught one real divergence during development — duplicate keys in cfg[0] resolved to the per-occurrence value instead of the last value — which is why cfg[0] is in the last-wins table. The test fails if that regression is reintroduced.
- Dotted keys (which require descending into nested structs) fall back to __wti_config_get — literally the old code path, identical by construction.
- Emit order (cfg[0] key order), quote preservation (WT_CONFIG_PRESERVE_QUOTES on both keys and values), trailing-comma handling, and error behavior (EINVAL on invalid key types, WT_NOTFOUND loop termination) are unchanged.
- Full catch2 suite (299 cases) and Python suite subsets covering config parsing, schema create, metadata cursors, dump, tiered, and all test_layered_schema* are green.
No on-disk impact: collapse output reaches persisted metadata in several places (file metadata, checkpoint metadata, alter, import), and in all of them the bytes are unchanged.
Definition of done
- Rewrite merged with the fuzz harness as a permanent test.