Make __wt_config_collapse linear in the configuration size

    • 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.

            Assignee:
            [DO NOT USE] Backlog - Storage Engines Team
            Reporter:
            Etienne Petrel
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: