Skip to content

Claude/extsort perf batching - #15

Merged
lanrat merged 7 commits into
mainfrom
claude/extsort-perf-batching
Sep 30, 2026
Merged

lanrat merged 7 commits into
mainfrom
claude/extsort-perf-batching

Conversation

@lanrat

@lanrat lanrat commented Sep 30, 2026

Copy link
Copy Markdown
Owner

No description provided.

lanrat and others added 7 commits September 30, 2026 13:12
Push wrapped each element in an item, boxed it for container/heap and
stored a pointer to a copy: two allocations per element. It then ran
heap.Fix on the index of the original, which was always 0, so it
re-sifted the root for nothing.

Keep the elements in a slice and sift them directly with the algorithm
of container/heap, so Push and Pop no longer allocate and the
comparisons are no longer interface calls. Pushing and popping through
a 64-element queue drops from 66.2 ns and 2 allocations to 23.9 ns and
none (BenchmarkPushPop); replacing the top of a 64-way merge drops from
38.9 ns to 22.2 ns (BenchmarkPeekUpdate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every read was a select on the data channel and ctx.Done(), and a
select locks both channels, so readers of a busy stream contended on
the context's channel for every value.

Try a non-blocking receive first and fall back to the select only when
the stream has nothing ready. ctx is then checked every 1024 reads,
starting with the first, so a diff with values always ready still sees
a cancellation, and a diff whose ctx is already cancelled returns
before reading anything (the select used to pick a case at random).
Diffing two streams of 1M ints drops from 179 ms to 70 ms
(BenchmarkDiffOrdered).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every sorter created the pool, and nothing ever took a slice from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each merge worker sent its records to the final merge one per channel
operation, every send a select on the same ctx.Done() channel as all
the other workers, so the handoffs cost more than the merge: most CPU
time went to the scheduler and to channel locks.

Workers now send batches of 1024 records, with up to two queued per
worker, and the final merge hands used-up batches back to the worker
for reuse. Errors, cancellation and panic recovery take the same paths
as before, and a worker checks ctx as it sends each batch, so it stops
within one batch of a cancel.

Sorting 1M ints with Generic drops from 350 ms to 134 ms
(BenchmarkGenericVarintInts) and 1M strings from 377 ms to 183 ms
(BenchmarkLegacyStringSort/size_1000000), and 2,000 chunks merge in
52 ms instead of 111 ms (BenchmarkSortManyChunks).

New tests make a read error partway into a chunk, a compareFunc panic
and a cancel each happen mid-merge, and check that the error reaches
the error channel, the temp file is closed and no sorter goroutine is
left running. Another covers record counts around the batch size.
trackedTemp gains readErrAfter to fail a section partway, and the
final merge panic test feeds the final merge through batch streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildChunks read every record with a select on the input and on
ctx.Done(), which locks both channels.

Try a non-blocking receive first and fall back to the select only when
the input has nothing ready. A steady input then costs one channel
operation per record, and ctx is checked every 1024 records so it
still sees a cancellation. Sorting 1M ints fed by a goroutine drops
from 134 ms to 109 ms (BenchmarkGenericVarintInts).

The output send keeps its select: a non-blocking send first made the
merge slower when measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The slice pool gave every chunk a slice of capacity ChunkSize, so
sorting 10 ints with the default config allocated 8 MB.

A chunk now starts at 1024 records and doubles, to exactly ChunkSize.
Once a chunk has filled, the input spans several chunks, so later
chunks take their full size at once instead of growing. Sorting 10
records drops from 47.7 µs and 7.6 MiB to 10.0 µs and 23 KiB
(BenchmarkSortTenRecords). Large sorts pay for growing the first chunk
once: BenchmarkGenericVarintInts allocates 3.5% more, in the same time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strings and Ordered allocated a slice to encode every record and
another to read it back in the merge.

Their codecs now append each record to one buffer reused across the
chunk, written together with its length header in a single Write, and
the merge reads all the records of a chunk into one reused buffer.
That is only safe because these fromBytes functions never keep the
slice they are given; Generic keeps allocating, since a user's
fromBytes may. The scratch-buffer pool for the length header becomes a
stack array.

Sorting 1M ints with Ordered drops from 45.4 MiB and 3.0M allocations
to 6.8 MiB and 277, and from 112 ms to 104 ms (BenchmarkOrderedInts).
1M strings take 34.9 MiB and 1M allocations instead of 51.4 MiB and
3M (BenchmarkLegacyStringSort/size_1000000).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lanrat
lanrat merged commit 0ff3472 into main Sep 30, 2026
3 checks passed
@lanrat
lanrat deleted the claude/extsort-perf-batching branch September 30, 2026 20:55
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