Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.9k
Add heap-based BPE merge path for large inputs (>128 bytes)#7580
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
35fd6b9d2d5f40d47b417dbd8ad2604bc908c0543d058c10b93697d0507035a394658ef339af1a43a57ebd86b948984a3df43f8ee99c5107File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -20,6 +20,15 @@ public static (int Id, int TokenIndex, int TokenLength)[] BytePairEncode(ReadOnl | ||
| return [(ranks[mergingBytes], 0, 1)]; | ||
| } | ||
| // For large inputs, use heap-based algorithm to avoid O(n²) behavior. | ||
| // Threshold of 128 chosen empirically: linear scan is cache-friendly for small inputs, | ||
| // while heap overhead (O(log n) per operation) becomes worthwhile for larger inputs. | ||
| // Based on upstream tiktoken using 100, adjusted upward for C#'s efficient span operations. | ||
| if (mergingBytes.Length > 128) | ||
| { | ||
| return BytePairEncodeLarge(mergingBytes, ranks, indexMappingSpan); | ||
| } | ||
| (int Index, int Rank)[]? arrayPoolArray = null; | ||
| int requiredLength = mergingBytes.Length + 1; | ||
| Span<(int Index, int Rank)> byteIndicesAndRanks = requiredLength <= 64 ? | ||
| @@ -116,6 +125,168 @@ int GetRank(Span<(int Index, int Rank)> byteIndicesAndRanks, int startIndex, int | ||
| return result; | ||
| } | ||
| private struct State | ||
| { | ||
| public int Prev; | ||
| public int End; | ||
| public int NextEnd; | ||
| public int NextRank; | ||
| // Note: In the Tiktoken tokenizer, the rank is also the token Id. | ||
| // This field is used to cache the rank/Id after a merge so we don't need to re-look it up. | ||
| // Using this code with a different tokenizer where rank != token Id would produce wrong results. | ||
| public int CurRank; | ||
tarekgh marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| private struct MergeEntry : IComparable<MergeEntry> | ||
| { | ||
| public int Rank; | ||
| public int Start; | ||
| public int CompareTo(MergeEntry other) | ||
| { | ||
| int rankComparison = Rank.CompareTo(other.Rank); | ||
| if (rankComparison != 0) | ||
| { | ||
| return rankComparison; | ||
| } | ||
| return Start.CompareTo(other.Start); | ||
| } | ||
| } | ||
stephentoub marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| private static (int Id, int TokenIndex, int TokenLength)[] BytePairEncodeLarge(ReadOnlyMemory<byte> mergingBytes, IReadOnlyDictionary<ReadOnlyMemory<byte>, int> ranks, ReadOnlySpan<int> indexMappingSpan) | ||
| { | ||
| int stateLength = mergingBytes.Length; | ||
| State[] statePoolArray = ArrayPool<State>.Shared.Rent(stateLength); | ||
| Span<State> state = statePoolArray.AsSpan(0, stateLength); | ||
| state[0] = new State | ||
| { | ||
| Prev = int.MaxValue, | ||
| End = 1, | ||
| NextEnd = 2, | ||
| NextRank = int.MaxValue, | ||
| CurRank = int.MaxValue | ||
| }; | ||
| var heap = new PriorityQueue<MergeEntry>(); | ||
| for (int i = 0; i < mergingBytes.Length - 1; i++) | ||
| { | ||
| var slice = mergingBytes.Slice(i, 2); | ||
| if (ranks.TryGetValue(slice, out int rank)) | ||
| { | ||
| heap.Enqueue(new MergeEntry { Start = i, Rank = rank }); | ||
| state[i].NextRank = rank; | ||
| } | ||
| state[i + 1] = new State | ||
| { | ||
| Prev = i, | ||
| End = i + 2, | ||
| NextEnd = i + 3, | ||
| NextRank = int.MaxValue, | ||
| CurRank = int.MaxValue | ||
| }; | ||
| } | ||
| // Local function to add a potential merge to the heap. | ||
| void PotentialMerge(Span<State> stateSpan, PriorityQueue<MergeEntry> heapQueue, int start, int nextEndItem) | ||
| { | ||
| stateSpan[start].NextEnd = nextEndItem; | ||
| stateSpan[start].NextRank = int.MaxValue; | ||
| if (nextEndItem <= mergingBytes.Length) | ||
| { | ||
| var slice = mergingBytes.Slice(start, nextEndItem - start); | ||
| if (ranks.TryGetValue(slice, out int rank)) | ||
| { | ||
| heapQueue.Enqueue(new MergeEntry { Start = start, Rank = rank }); | ||
| stateSpan[start].NextRank = rank; | ||
| } | ||
| } | ||
| } | ||
| while (heap.Count > 0) | ||
| { | ||
| MergeEntry left = heap.Dequeue(); | ||
| if (left.Rank == int.MaxValue) | ||
| { | ||
| break; | ||
| } | ||
| if (left.Rank != state[left.Start].NextRank) | ||
| { | ||
| continue; | ||
| } | ||
| int leftStart = left.Start; | ||
| int rightStart = state[leftStart].End; | ||
| int rightEnd = state[leftStart].NextEnd; | ||
| int rightNextEnd = state[rightStart].NextEnd; | ||
| state[leftStart].CurRank = state[leftStart].NextRank; | ||
| state[leftStart].End = rightEnd; | ||
| PotentialMerge(state, heap, leftStart, rightNextEnd); | ||
| if (rightEnd < state.Length) | ||
| { | ||
| state[rightEnd].Prev = leftStart; | ||
| } | ||
| if (leftStart > 0) | ||
| { | ||
| int prevStart = state[leftStart].Prev; | ||
| PotentialMerge(state, heap, prevStart, rightEnd); | ||
| } | ||
| state[rightStart].NextRank = int.MaxValue; | ||
| } | ||
| // Use ArrayPool for the result buffer to avoid List<T> overhead. | ||
| // The maximum number of tokens is mergingBytes.Length (no merges). | ||
| var resultPoolArray = ArrayPool<(int Id, int TokenIndex, int TokenLength)>.Shared.Rent(mergingBytes.Length); | ||
| int resultCount = 0; | ||
| int currentIndex = 0; | ||
| while (currentIndex < state.Length) | ||
| { | ||
| int startIndex = currentIndex; | ||
| int endIndex = state[currentIndex].End; | ||
| int mappedStartIndex = indexMappingSpan[startIndex]; | ||
| int mappedEndIndex = indexMappingSpan[endIndex]; | ||
| int finalEndIndex = endIndex; | ||
| // Handle partial characters/elements at token boundaries. | ||
| // If the byte at endIndex-1 maps to the same character as endIndex, | ||
| // extend the token to include the complete character. | ||
| if (finalEndIndex > 0 && indexMappingSpan[finalEndIndex - 1] == mappedEndIndex) | ||
| { | ||
| finalEndIndex++; | ||
| while (finalEndIndex < indexMappingSpan.Length && indexMappingSpan[finalEndIndex] == mappedEndIndex) | ||
| { | ||
| finalEndIndex++; | ||
| } | ||
| } | ||
stephentoub marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| int tokenId = state[currentIndex].CurRank != int.MaxValue | ||
| ? state[currentIndex].CurRank | ||
| : ranks[mergingBytes.SliceStartEnd(startIndex, endIndex)]; | ||
| resultPoolArray[resultCount++] = (tokenId, mappedStartIndex, indexMappingSpan[finalEndIndex] - mappedStartIndex); | ||
| currentIndex = state[currentIndex].End; | ||
| } | ||
| ArrayPool<State>.Shared.Return(statePoolArray); | ||
| var result = resultPoolArray.AsSpan(0, resultCount).ToArray(); | ||
| ArrayPool<(int Id, int TokenIndex, int TokenLength)>.Shared.Return(resultPoolArray); | ||
| return result; | ||
| } | ||
| private static ReadOnlyMemory<byte> SliceStartEnd(this ReadOnlyMemory<byte> memory, int start, int end) => memory.Slice(start, end - start); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.