From c0593cb8cf44f34b776ef124f04cf3ac94ed3b82 Mon Sep 17 00:00:00 2001 From: Soreepeong Date: Fri, 1 Mar 2024 13:08:26 +0900 Subject: [PATCH] Implement stream pooling and more fine grained lock --- src/Lumina/Data/SqPack.cs | 117 ++++++++++++++-------------- src/Lumina/Data/SqPackStreamPool.cs | 110 ++++++++++++++++++++++++++ src/Lumina/GameData.cs | 28 ++++++- src/Lumina/Lumina.csproj | 1 + 4 files changed, 196 insertions(+), 60 deletions(-) create mode 100644 src/Lumina/Data/SqPackStreamPool.cs diff --git a/src/Lumina/Data/SqPack.cs b/src/Lumina/Data/SqPack.cs index fd67d067..1bf18d3e 100644 --- a/src/Lumina/Data/SqPack.cs +++ b/src/Lumina/Data/SqPack.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Reflection; @@ -10,13 +11,14 @@ namespace Lumina.Data public class SqPackInflateException : Exception { public SqPackInflateException( string message ) : base( message ) - { - } + { } } + /// Represents a .dat file of a sqpack file group. public class SqPack { private readonly GameData _gameData; + private readonly ConcurrentDictionary< long, WeakReference< FileResource > > _cache; /// /// Where the actual file is located on disk @@ -33,22 +35,26 @@ public class SqPack /// public string FullName => File.FullName; + /// + /// Gets the file header of this SqPack file. + /// public SqPackHeader SqPackHeader { get; private set; } /// /// PC and PS4 are LE, PS3 is BE /// [Obsolete( "Use property \"ConvertEndianness\" from \"LuminaBinaryReader\" instead" )] - public bool ShouldConvertEndianness - { + public bool ShouldConvertEndianness { // todo: what about reading LE files on BE device? do we even care? get => BitConverter.IsLittleEndian && SqPackHeader.platformId == PlatformId.PS3 || - !BitConverter.IsLittleEndian && SqPackHeader.platformId != PlatformId.PS3; + !BitConverter.IsLittleEndian && SqPackHeader.platformId != PlatformId.PS3; } - protected readonly Dictionary< long, WeakReference< FileResource > > FileCache; - - protected readonly object CacheLock = new object(); + /// Unused field. + [Obsolete( "Not used." )] protected readonly Dictionary< long, WeakReference< FileResource > > FileCache = new(); + + /// Unused field. + [Obsolete( "Not used." )] protected readonly object CacheLock = new(); internal SqPack( FileInfo file, GameData gameData ) { @@ -60,77 +66,70 @@ internal SqPack( FileInfo file, GameData gameData ) _gameData = gameData; // always init the cache just in case the should cache setting is changed later - FileCache = new Dictionary< long, WeakReference< FileResource > >(); + _cache = new(); File = file; - using var ss = new SqPackStream( File ); - - SqPackHeader = ss.GetSqPackHeader(); - } - - protected T? GetCachedFile< T >( long offset ) where T : FileResource - { - if( !FileCache.TryGetValue( offset, out var weakRef ) ) - { - return null; - } - - if( !weakRef.TryGetTarget( out var cachedFile ) ) - { - return null; - } - - // only return from cache if target type matches - // otherwise we'll force a cache miss and parse it as per usual - if( cachedFile is T obj ) - { - return obj; - } - - return null; + using var sh = GetStreamHolder(); + SqPackHeader = sh.Stream.GetSqPackHeader(); } + /// Gets the file metadata of a file at the given offset. + /// The byte offset of a file in this SqPack .dat file. + /// The corresponding file metadata. + /// No verification is performed on whether points to a valid file metadata. public SqPackFileInfo GetFileMetadata( long offset ) { - using var ss = new SqPackStream( File, SqPackHeader.platformId ); - - return ss.GetFileMetadata( offset ); + using var sh = GetStreamHolder(); + return sh.Stream.GetFileMetadata( offset ); } + /// Reads the file at the given offset. + /// The byte offset of a file in this SqPack .dat file. + /// The file resource type. + /// The corresponding file resource. public T ReadFile< T >( long offset ) where T : FileResource { var cacheBehaviour = FileOptionsAttribute.FileCacheBehaviour.None; - - var fileOpts = typeof( T ).GetCustomAttribute< FileOptionsAttribute >(); - if( fileOpts != null ) - { + + if( typeof( T ).GetCustomAttribute< FileOptionsAttribute >() is { } fileOpts ) cacheBehaviour = fileOpts.CacheBehaviour; - } - + if( !_gameData.Options.CacheFileResources || cacheBehaviour == FileOptionsAttribute.FileCacheBehaviour.Never ) { - using var ss = new SqPackStream( File, SqPackHeader.platformId ); - return ss.ReadFile< T >( offset ); + using var rsh = GetStreamHolder(); + return rsh.Stream.ReadFile< T >( offset ); } - - - lock( CacheLock ) - { - var obj = GetCachedFile< T >( offset ); - if( obj != null ) - { - return obj; - } + // WeakReference ctor accepts null. + var weakRef = _cache.GetOrAdd( offset, static _ => new( null! ) ); + if( weakRef.TryGetTarget( out var valueUntyped ) && valueUntyped is T value ) + return value; - using var ss = new SqPackStream( File, SqPackHeader.platformId ); - var file = ss.ReadFile< T >( offset ); - - FileCache[ offset ] = new WeakReference< FileResource >( file ); + lock( weakRef ) + { + if( weakRef.TryGetTarget( out valueUntyped ) && valueUntyped is T value2 ) + return value2; - return file; + using var sh = GetStreamHolder(); + var f = sh.Stream.ReadFile< T >( offset ); + weakRef.SetTarget( f ); + return f; } } + + /// Gets the file, if the cached instance of can be cast as . + /// The byte offset of a file in this SqPack .dat file. + /// The file resource type. + /// The corresponding file resource, or null if the file was not cached. + protected T? GetCachedFile< T >( long offset ) where T : FileResource => + _cache.TryGetValue( offset, out var cached ) && cached.TryGetTarget( out var valueUntyped ) ? valueUntyped as T : null; + + /// Rents a stream from the pool if stream pooling is enabled, or creates a new stream. + /// A stream holder that will either return the stream to the pool or close the stream. + private SqPackStreamPool.StreamHolder GetStreamHolder() => + _gameData.StreamPool is { } streamPool + ? streamPool.RentScoped( this ) + : new( new SqPackStream( File, SqPackHeader.platformId ) ); } } \ No newline at end of file diff --git a/src/Lumina/Data/SqPackStreamPool.cs b/src/Lumina/Data/SqPackStreamPool.cs new file mode 100644 index 00000000..a1719d7f --- /dev/null +++ b/src/Lumina/Data/SqPackStreamPool.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading; +using Microsoft.Extensions.ObjectPool; + +namespace Lumina.Data; + +/// A stream pool for use with . +/// +/// As creating a handle for a file on the filesystem takes much longer than seeking an already opened handle, reusing handles will help with the overall +/// performance, both single-threaded and multi-threaded. +/// +public sealed class SqPackStreamPool : IDisposable +{ + private readonly ObjectPoolProvider _objectPoolProvider; + private ConcurrentDictionary< SqPack, ObjectPool< SqPackStream > >? _pools = new(); + + /// Initializes a new instance of class. + /// The maximum number of streams to retain in the pool per file. If a non-positive number is given, the default value of + /// will be used. + public SqPackStreamPool( int maximumRetained = 0 ) + { + var dopp = new DefaultObjectPoolProvider(); + if( maximumRetained > 0 ) + dopp.MaximumRetained = maximumRetained; + _objectPoolProvider = dopp; + } + + /// Initializes a new instance of class. + /// The object pool provider to use. + public SqPackStreamPool( ObjectPoolProvider objectPoolProvider ) => _objectPoolProvider = objectPoolProvider; + + /// + public void Dispose() + { + if( Interlocked.Exchange( ref _pools, null ) is not { } pools ) + return; + + foreach( var pool in pools.Values ) + ( pool as IDisposable )?.Dispose(); + pools.Clear(); + } + + /// Rents a stream from the pool. + /// An instance of to get the stream of. + /// The rented stream. + /// If this pool has been disposed, a new instance of will be created instead. + internal StreamHolder RentScoped( SqPack dat ) + { + if( _pools is not { } pools ) + return new( new SqPackStream( dat.File, dat.SqPackHeader.platformId ) ); + + var pool = pools.GetOrAdd( + dat, + static ( key, owner ) => owner._objectPoolProvider.Create( new SqPackStreamObjectPoolProvider( owner, key ) ), + this ); + return new( pool ); + } + + /// A struct facilitating the scoped borrowing from a pool. + /// Multithreaded usage is not supported. + internal struct StreamHolder : IDisposable + { + private readonly ObjectPool< SqPackStream >? _pool; + private SqPackStream? _stream; + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public StreamHolder( ObjectPool< SqPackStream > pool ) + { + _pool = pool; + _stream = pool.Get(); + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public StreamHolder( SqPackStream stream ) => _stream = stream; + + public SqPackStream Stream { + [MethodImpl( MethodImplOptions.AggressiveInlining )] + get => _stream ?? throw new ObjectDisposedException( nameof( StreamHolder ) ); + } + + public void Dispose() + { + if( _stream is null ) + return; + if( _pool is null ) + _stream.Dispose(); + else + _pool.Return( _stream ); + _stream = null; + } + } + + private class SqPackStreamObjectPoolProvider : IPooledObjectPolicy< SqPackStream > + { + private readonly SqPackStreamPool _owner; + private readonly SqPack _sqpack; + + public SqPackStreamObjectPoolProvider( SqPackStreamPool owner, SqPack sqpack ) + { + _owner = owner; + _sqpack = sqpack; + } + + public SqPackStream Create() => new( _sqpack.File, _sqpack.SqPackHeader.platformId ); + + public bool Return( SqPackStream obj ) => _owner._pools is not null && obj.BaseStream.CanRead; + } +} \ No newline at end of file diff --git a/src/Lumina/GameData.cs b/src/Lumina/GameData.cs index 77e4bb06..06b02588 100644 --- a/src/Lumina/GameData.cs +++ b/src/Lumina/GameData.cs @@ -13,8 +13,10 @@ namespace Lumina { - public class GameData + public class GameData : IDisposable { + ~GameData() => Dispose( false ); + /// /// The current data path that Lumina is using to load files. /// @@ -50,6 +52,12 @@ public class GameData /// easily defer file loading onto another thread. /// public FileHandleManager FileHandleManager { get; private set; } + + /// + /// Provides a pool for file streams for .dat files. + /// + /// The pool will be disposed when is called. + public SqPackStreamPool? StreamPool { get; set; } internal ILogger? Logger { get; private set; } @@ -152,6 +160,13 @@ public GameData( string dataPath, ILogger logger, LuminaOptions? options = null! }; } + /// + public void Dispose() + { + Dispose( true ); + GC.SuppressFinalize( this ); + } + /// /// Load a raw file given a game file path /// @@ -315,6 +330,17 @@ public void ProcessFileHandleQueue() FileHandleManager.ProcessQueue(); } + /// Disposes this object. + /// Whether this function is being called from . + protected virtual void Dispose( bool disposing ) + { + if( disposing ) + { + StreamPool?.Dispose(); + StreamPool = null; + } + } + internal void SetCurrentContext() { SetCurrentContext( this ); diff --git a/src/Lumina/Lumina.csproj b/src/Lumina/Lumina.csproj index c9fc82b4..9c396166 100644 --- a/src/Lumina/Lumina.csproj +++ b/src/Lumina/Lumina.csproj @@ -27,6 +27,7 @@ + all