Skip to content

perf: single-pass multi-path msgpack_extract (O(K*N) -> O(N)) - #14

Merged
khanaffan merged 1 commit into
mainfrom
perf/single-pass-multi-extract
Jul 2, 2026
Merged

perf: single-pass multi-path msgpack_extract (O(K*N) -> O(N))#14
khanaffan merged 1 commit into
mainfrom
perf/single-pass-multi-extract

Conversation

@khanaffan

Copy link
Copy Markdown
Owner

Summary

msgpack_extract(mp, p1, …, pK) (the multi-path form that returns a msgpack array) currently resolves each path with an independent mpLookup() scan from the start of the container — O(K·N) for K paths over an N-element map/array. This PR gathers all requested elements in one linear O(N) pass when the root is a container and every path is a single component ($.key or $[idx]) and K ≥ 16.

How

  • mpScanMap — builds an open-addressing hash of the requested keys (with per-key chains for duplicate paths); each map entry is matched in O(1).
  • mpScanArray — builds a direct index table (idx → chain), bounded by a size guard for sparse indices.
  • mpExtractMulti dispatches to the fast path; any other case (few paths, non-container root, non-simple/nested path, malformed structure, OOM) falls back to the original per-path loop, so the returned bytes are identical in every case.
  • Paths are parsed locally per call, not cached in per-argument sqlite3_set_auxdata() — SQLite's auxdata lookup is O(#entries), so per-path caching would make a K-path call O(K²) (this was measured, and regressed array extraction). Documented in-code.

Security hardening

Fixes a potential out-of-bounds read in the new map key decode: a malformed STR32 key whose declared length is near 0xFFFFFFFF would wrap the 32-bit value offset past the valOff <= n guard and drive an OOB read when hashing the key. The length is now validated against the available bytes first. (The original mpLookup never hashed the stored key, so this is specific to the fast path and is guarded from the outset.)

Benchmarks

10k rows, one msgpack_extract call per row (standalone sqlite3.c + msgpack.c, -O2):

Workload (N=400) Original This PR Speedup
Extract all N keys (map) 316 µs/row 6.9 µs/row ~46x
Extract all N indices (array) 148 µs/row 3.7 µs/row ~40x
Extract 16-field subset (map) 311 µs/row 2.8 µs/row ~109x
Extract 16-field subset (array) 148 µs/row 1.2 µs/row ~119x
Small K (few paths) unchanged (original loop)

Validation

  • Differential: 2,742 cases (curated + randomized fuzz) — byte-for-byte identical to the original multi-path output (incl. duplicate paths, missing keys / out-of-range indices → NIL, first-occurrence semantics, nested-path fallback).
  • ASan/UBSan: clean across all suites + adversarial malformed blobs, including the STR32 overflow repro.
  • Repo suite: ctest 16/16 pass; adds Phase 4.11 multi-path tests to tests/test_spec_p4_extract.c (map/array single-pass, nested-path fallback, malformed-STR32 no-overflow) — 75/75 in that file, clean under ASan.

Notes

  • Callers benefit by using the multi-path form msgpack_extract(mp,'$.pA','$.pB',…) (or msgpack_each); N separate single-path calls are unaffected.
  • No public API or output-format change.

msgpack_extract(mp, p1, ..., pK) previously resolved each path with an
independent mpLookup() scan from the start of the container — O(K*N) for K
paths over an N-element map/array. When the root is a container and every
path is a single component ($.key or $[idx]) and K >= 16, gather all
requested elements in ONE linear pass:

  * mpScanMap   — open-addressing hash of the requested keys (chained for
                  duplicate paths); each map entry matched in O(1).
  * mpScanArray — direct index table (idx -> chain), bounded by a size guard.

Any other case (few paths, non-container root, non-simple path, malformed
structure, OOM) falls back to the original per-path loop, so the returned
bytes are identical in every case.

Paths are parsed locally per call rather than cached in per-argument
sqlite3_set_auxdata(): SQLite's auxdata lookup is O(#entries), so per-path
caching would make a K-path call O(K^2).

Also hardens the map key decode against a malformed STR32 key whose declared
length near 0xFFFFFFFF would wrap the 32-bit value offset past the valOff<=n
guard and drive an out-of-bounds read when hashing the key; the length is now
validated against the available bytes first.

Benchmarks (10k rows, one call/row): extracting all N fields ~40-46x faster
at N=400 (map and array); a 16-field subset of an N=400 record ~100-119x
faster; small path counts unchanged. Validated with differential tests vs the
original (byte-identical) and ASan/UBSan clean, incl. adversarial malformed
blobs. Adds Phase 4.11 multi-path tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@khanaffan
khanaffan merged commit 70a72fd into main Jul 2, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant