A GPT-2 byte-level BPE tokenizer implementation. It loads tokenizer data from JSON or a mounted filesystem, builds byte encoder/decoder maps, applies BPE merges, encodes strings to token id arrays and decodes id arrays back to text, and supports special added tokens.
using System.Text;
using System.Text.RegularExpressions;
using Sandbox.Diagnostics;
namespace LlmPoc.Llm;
public sealed class Gpt2ByteBpeTokenizer
{
private const string PairSeparator = "\0";
private const string Gpt2Pattern = @"'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+";
private static readonly Regex TokenPattern = new(
Gpt2Pattern,
RegexOptions.CultureInvariant );
private readonly Dictionary<string, int> _vocabulary;
private readonly string[] _tokensById;
private readonly Dictionary<string, int> _mergeRanks;
private readonly Dictionary<string, string[]> _bpeCache = new( StringComparer.Ordinal );
private readonly char[] _byteEncoder = new char[256];
private readonly Dictionary<char, byte> _byteDecoder = new();
private readonly List<Gpt2AddedTokenDocument> _addedTokens;
private readonly Dictionary<int, Gpt2AddedTokenDocument> _addedTokensById;
public int VocabularySize => _tokensById.Length;
public int MergeCount => _mergeRanks.Count;
private Gpt2ByteBpeTokenizer( Gpt2TokenizerDocument document )
{
ValidateDocument( document );
_vocabulary = new Dictionary<string, int>( document.Model.Vocabulary, StringComparer.Ordinal );
_tokensById = new string[_vocabulary.Count];
foreach ( KeyValuePair<string, int> item in _vocabulary )
{
if ( item.Value < 0 || item.Value >= _tokensById.Length )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Token '{item.Key}' has ID {item.Value}, outside " +
$"[0,{_tokensById.Length})." );
}
if ( _tokensById[item.Value] is not null )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Token ID {item.Value} is assigned to both " +
$"'{_tokensById[item.Value]}' and '{item.Key}'." );
}
_tokensById[item.Value] = item.Key;
}
for ( int id = 0; id < _tokensById.Length; id++ )
{
if ( _tokensById[id] is null )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer vocabulary has no token for ID {id}." );
}
}
_mergeRanks = new Dictionary<string, int>( document.Model.Merges.Count, StringComparer.Ordinal );
for ( int rank = 0; rank < document.Model.Merges.Count; rank++ )
{
string[] pair = document.Model.Merges[rank];
if ( pair is null || pair.Length != 2 || pair[0] is null || pair[1] is null )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer merge rank {rank} must contain exactly two strings." );
}
string key = PairKey( pair[0], pair[1] );
if ( !_mergeRanks.TryAdd( key, rank ) )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Duplicate tokenizer merge pair at rank {rank}: " +
$"'{pair[0]}' + '{pair[1]}'." );
}
}
_addedTokens = document.AddedTokens?
.Where( token => token is not null && !string.IsNullOrEmpty( token.Content ) )
.OrderByDescending( token => token.Content.Length )
.ToList() ?? new List<Gpt2AddedTokenDocument>();
_addedTokensById = _addedTokens.ToDictionary( token => token.Id );
BuildByteMaps();
}
public static Gpt2ByteBpeTokenizer LoadFromMounted( string path )
{
if ( !FileSystem.Mounted.FileExists( path ) )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer file '{path}' is not present in FileSystem.Mounted." );
}
FastTimer timer = FastTimer.StartNew();
Gpt2TokenizerDocument document = FileSystem.Mounted.ReadJson<Gpt2TokenizerDocument>( path );
if ( document is null )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer file '{path}' deserialized to null." );
}
Gpt2ByteBpeTokenizer tokenizer = new( document );
LlmLog.Info(
"TOKEN",
$"Loaded GPT-2 byte-level BPE tokenizer with {tokenizer.VocabularySize:N0} tokens " +
$"and {tokenizer.MergeCount:N0} merges in {timer.ElapsedMilliSeconds:N2} ms." );
return tokenizer;
}
public static Gpt2ByteBpeTokenizer LoadFromJson(
string json,
string sourceLabel = "in-memory tokenizer JSON" )
{
if ( string.IsNullOrWhiteSpace( json ) )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer JSON from '{sourceLabel}' is empty." );
}
FastTimer timer = FastTimer.StartNew();
Gpt2TokenizerDocument document = Json.Deserialize<Gpt2TokenizerDocument>( json );
if ( document is null )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer JSON from '{sourceLabel}' deserialized to null." );
}
Gpt2ByteBpeTokenizer tokenizer = new( document );
LlmLog.Info(
"TOKEN",
$"Loaded packaged GPT-2 byte-level BPE tokenizer with " +
$"{tokenizer.VocabularySize:N0} tokens and {tokenizer.MergeCount:N0} merges " +
$"in {timer.ElapsedMilliSeconds:N2} ms." );
return tokenizer;
}
public int[] Encode( string text )
{
if ( text is null )
{
throw new ArgumentNullException( nameof( text ) );
}
FastTimer timer = FastTimer.StartNew();
List<int> result = new();
int position = 0;
while ( position < text.Length )
{
Gpt2AddedTokenDocument nextSpecial = null;
int nextSpecialIndex = int.MaxValue;
foreach ( Gpt2AddedTokenDocument token in _addedTokens )
{
int index = text.IndexOf( token.Content, position, StringComparison.Ordinal );
if ( index >= 0 && index < nextSpecialIndex )
{
nextSpecial = token;
nextSpecialIndex = index;
}
}
if ( nextSpecial is null )
{
EncodeOrdinary( text[position..], result );
break;
}
if ( nextSpecialIndex > position )
{
EncodeOrdinary( text[position..nextSpecialIndex], result );
}
result.Add( nextSpecial.Id );
position = nextSpecialIndex + nextSpecial.Content.Length;
}
LlmLog.Trace(
"TOKEN",
$"Encoded chars={text.Length:N0} tokens={result.Count:N0} " +
$"ids=[{string.Join( ",", result )}] in {timer.ElapsedMilliSeconds:N3} ms." );
return result.ToArray();
}
public string Decode( IReadOnlyList<int> tokenIds )
{
if ( tokenIds is null )
{
throw new ArgumentNullException( nameof( tokenIds ) );
}
StringBuilder output = new();
List<byte> bytes = new();
for ( int index = 0; index < tokenIds.Count; index++ )
{
int tokenId = tokenIds[index];
ValidateTokenId( tokenId, index );
if ( _addedTokensById.TryGetValue( tokenId, out Gpt2AddedTokenDocument addedToken ) )
{
FlushBytes( bytes, output );
output.Append( addedToken.Content );
continue;
}
string token = _tokensById[tokenId];
for ( int characterIndex = 0; characterIndex < token.Length; characterIndex++ )
{
char character = token[characterIndex];
if ( !_byteDecoder.TryGetValue( character, out byte value ) )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Token ID {tokenId} contains character U+{(int)character:X4} " +
"which is absent from the GPT-2 byte decoder." );
}
bytes.Add( value );
}
}
FlushBytes( bytes, output );
string decoded = output.ToString();
LlmLog.Trace(
"TOKEN",
$"Decoded tokens={tokenIds.Count:N0} chars={decoded.Length:N0}." );
return decoded;
}
private void EncodeOrdinary( string text, List<int> output )
{
MatchCollection matches = TokenPattern.Matches( text );
foreach ( Match match in matches )
{
byte[] utf8 = Encoding.UTF8.GetBytes( match.Value );
StringBuilder encoded = new( utf8.Length );
for ( int index = 0; index < utf8.Length; index++ )
{
encoded.Append( _byteEncoder[utf8[index]] );
}
string[] pieces = ApplyBpe( encoded.ToString() );
for ( int pieceIndex = 0; pieceIndex < pieces.Length; pieceIndex++ )
{
string piece = pieces[pieceIndex];
if ( !_vocabulary.TryGetValue( piece, out int tokenId ) )
{
throw new InvalidOperationException(
$"[LLM:ERROR] GPT-2 BPE produced token '{Escape( piece )}' " +
$"which is absent from the {VocabularySize:N0}-entry vocabulary." );
}
output.Add( tokenId );
}
}
}
private string[] ApplyBpe( string token )
{
if ( _bpeCache.TryGetValue( token, out string[] cached ) )
{
return cached;
}
List<string> word = new( token.Length );
for ( int index = 0; index < token.Length; index++ )
{
word.Add( token[index].ToString() );
}
while ( word.Count > 1 )
{
int bestRank = int.MaxValue;
string bestLeft = null;
string bestRight = null;
for ( int index = 0; index < word.Count - 1; index++ )
{
if ( _mergeRanks.TryGetValue( PairKey( word[index], word[index + 1] ), out int rank ) &&
rank < bestRank )
{
bestRank = rank;
bestLeft = word[index];
bestRight = word[index + 1];
}
}
if ( bestLeft is null )
{
break;
}
List<string> merged = new( word.Count );
int position = 0;
while ( position < word.Count )
{
if ( position < word.Count - 1 &&
word[position] == bestLeft && word[position + 1] == bestRight )
{
merged.Add( bestLeft + bestRight );
position += 2;
}
else
{
merged.Add( word[position] );
position++;
}
}
word = merged;
}
string[] result = word.ToArray();
_bpeCache[token] = result;
return result;
}
private void ValidateTokenId( int tokenId, int position )
{
if ( tokenId < 0 || tokenId >= _tokensById.Length )
{
throw new IndexOutOfRangeException(
$"[LLM:ERROR] Token ID {tokenId} at sequence index {position} is outside " +
$"[0,{_tokensById.Length})." );
}
}
private static void ValidateDocument( Gpt2TokenizerDocument document )
{
if ( document is null )
{
throw new ArgumentNullException( nameof( document ) );
}
if ( document.Version != "1.0" )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer JSON version expected 1.0, found '{document.Version}'." );
}
if ( document.Normalizer is not null )
{
throw new InvalidOperationException(
"[LLM:ERROR] Exported tokenizer unexpectedly defines a normalizer." );
}
if ( document.PreTokenizer is null || document.PreTokenizer.Type != "ByteLevel" ||
document.PreTokenizer.AddPrefixSpace || !document.PreTokenizer.UseRegex )
{
throw new InvalidOperationException(
"[LLM:ERROR] Tokenizer requires ByteLevel pre-tokenization with " +
"add_prefix_space=false and use_regex=true." );
}
if ( document.Decoder is null || document.Decoder.Type != "ByteLevel" )
{
throw new InvalidOperationException(
"[LLM:ERROR] Tokenizer requires a ByteLevel decoder." );
}
if ( document.Model is null || document.Model.Type != "BPE" )
{
throw new InvalidOperationException(
$"[LLM:ERROR] Tokenizer model expected BPE, found '{document.Model?.Type}'." );
}
if ( document.Model.Dropout is not null || document.Model.ByteFallback ||
document.Model.IgnoreMerges || document.Model.FuseUnknown ||
!string.IsNullOrEmpty( document.Model.UnknownToken ) ||
!string.IsNullOrEmpty( document.Model.ContinuingSubwordPrefix ) ||
!string.IsNullOrEmpty( document.Model.EndOfWordSuffix ) )
{
throw new InvalidOperationException(
"[LLM:ERROR] Exported tokenizer uses unsupported BPE options." );
}
if ( document.Model.Vocabulary is null || document.Model.Vocabulary.Count == 0 )
{
throw new InvalidOperationException( "[LLM:ERROR] Tokenizer vocabulary is empty." );
}
if ( document.Model.Merges is null )
{
throw new InvalidOperationException( "[LLM:ERROR] Tokenizer merges array is missing." );
}
}
private void BuildByteMaps()
{
List<int> mappedBytes = new();
for ( int value = 0; value < 256; value++ )
{
if ( IsDirectByte( value ) )
{
mappedBytes.Add( value );
}
}
List<int> mappedCodePoints = new( mappedBytes );
int extra = 0;
for ( int value = 0; value < 256; value++ )
{
if ( !IsDirectByte( value ) )
{
mappedBytes.Add( value );
mappedCodePoints.Add( 256 + extra );
extra++;
}
}
for ( int index = 0; index < mappedBytes.Count; index++ )
{
byte source = (byte)mappedBytes[index];
char encoded = (char)mappedCodePoints[index];
_byteEncoder[source] = encoded;
_byteDecoder.Add( encoded, source );
}
}
private static bool IsDirectByte( int value )
{
return (value >= 33 && value <= 126) ||
(value >= 161 && value <= 172) ||
(value >= 174 && value <= 255);
}
private static string PairKey( string left, string right )
{
return left + PairSeparator + right;
}
private static void FlushBytes( List<byte> bytes, StringBuilder output )
{
if ( bytes.Count == 0 )
{
return;
}
output.Append( Encoding.UTF8.GetString( bytes.ToArray() ) );
bytes.Clear();
}
private static string Escape( string value )
{
StringBuilder result = new();
for ( int index = 0; index < value.Length; index++ )
{
char character = value[index];
if ( char.IsControl( character ) )
{
result.Append( $"\\u{(int)character:X4}" );
}
else
{
result.Append( character );
}
}
return result.ToString();
}
}