Skip to content

Fix O(n) walk in Access.slice/1 for :get_and_update past range.last#15336

Merged
josevalim merged 1 commit intoelixir-lang:mainfrom
PJUllrich:improve-get-and-update-slice-in-access
May 2, 2026
Merged

Fix O(n) walk in Access.slice/1 for :get_and_update past range.last#15336
josevalim merged 1 commit intoelixir-lang:mainfrom
PJUllrich:improve-get-and-update-slice-in-access

Conversation

@PJUllrich
Copy link
Copy Markdown
Contributor

@PJUllrich PJUllrich commented May 2, 2026

Disclosure: I used Opus 4.7 to find this small performance improvement and suggest a fix which I then implemented, benchmarked, and tested myself.

Change

get_and_update_slice/6 walks the full list even after index > range.last and just adds every element after the range to the aggregator without updating it, then reverses the complete list. I added a guard clause that short-circuits once we're past range.last, reverses only the updated slice and appends the untouched tail via :lists.reverse(updates, rest).

This drops the complexity from O(n) to O(last + 1) for prefix/short slices over long lists.

Benchmark

Benchmark (Benchee, 1s warmup, 3s measurement; M2 Pro, OTP 28.4)

Benchmark Before After Change
slice :get_and_update start 0..5 of 10k 244 μs 0.25 μs ~977× faster
slice :get_and_update mid 100..120 of 10k 244 μs 3.0 μs ~82× faster
slice :get_and_update full 0..-1 of 1k 29 μs 31 μs within noise (±14.09%)

Other improvements considered but discarded

I also looked at 4 other potential improvements, but they were all slower in the benchmarks. I'm adding them here for documentation purposes:

Considered and discarded

  • Replace Enum.slice |> Enum.map in slice(:get, …) with a single recursive walk. Hypothesis: avoid the intermediate list. Reality: Enum.slice/2 on lists hits a BIF-backed fast path; the hand-rolled walk with index in range (which decodes a Range struct each iteration) was 2–9× slower across all sizes.
  • Seed values(:get_and_update, %{}, …) with data instead of %{} and use Map.replace! vs Map.put/2 Hypothesis: avoid rebuilding the map from %{}. Reality: Map.replace! (lookup + path-copy) is more expensive per call than Map.put building from empty for 1k-entry hash maps; no-pop case regressed 25%, half-pop 2×.
  • Also in values(:get_and_update, accumulate {key, update} pairs and finalize with Map.new/1 (:maps.from_list). Hypothesis: one BIF beats N Map.put calls. Reality: marginal at best — no-pop ~6% faster (within original ±84% deviation), half-pop ~13% slower due to extra list-build overhead.
  • Replace Enum.reverse/1 calls and the Enum.reduce+Enum.reverse pair in values_keyword(:get_and_update, …). Predicted impact was marginal; left for a future pass since none of the surrounding changes landed.

@josevalim josevalim merged commit 03c123a into elixir-lang:main May 2, 2026
15 checks passed
@josevalim
Copy link
Copy Markdown
Member

💚 💙 💜 💛 ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants