diff --git a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs index 27822313..53e697a0 100644 --- a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs +++ b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs @@ -78,6 +78,12 @@ public partial class SevenZipArchive : AbstractArchive + /// Async counterpart of the caching GetFolderStream overload. Reuses a cached decoder + /// stream for the folder when possible instead of recreating and re-decoding from the start. + /// + internal async ValueTask GetFolderStreamAsync( + Stream stream, + CFolder folder, + IPasswordProvider pw, + long skipSize, + long entrySize, + CancellationToken cancellationToken + ) + { + if (_cachedFolder == folder && _cachedFolderStream != null) + { + if (skipSize >= _cachedFolderStreamPosition) + { + var delta = skipSize - _cachedFolderStreamPosition; + if (delta > 0) + { + await _cachedFolderStream + .SkipAsync(delta, cancellationToken) + .ConfigureAwait(false); + } + // Assume the caller will fully consume the returned entry stream, advancing the + // shared stream by entrySize bytes. + _cachedFolderStreamPosition = skipSize + entrySize; + return _cachedFolderStream; + } + + // Non-sequential (backward) access within the same folder requires restarting. + await DisposeCachedFolderStreamAsync().ConfigureAwait(false); + } + else if (_cachedFolderStream != null) + { + await DisposeCachedFolderStreamAsync().ConfigureAwait(false); + } + + var newStream = await GetFolderStreamAsync(stream, folder, pw, cancellationToken) + .ConfigureAwait(false); + if (skipSize > 0) + { + await newStream.SkipAsync(skipSize, cancellationToken).ConfigureAwait(false); + } + + _cachedFolder = folder; + _cachedFolderStream = newStream; + _cachedFolderStreamPosition = skipSize + entrySize; + return newStream; + } + + private async ValueTask DisposeCachedFolderStreamAsync() + { + if (_cachedFolderStream is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { +#pragma warning disable VSTHRD103 // Fallback for streams that do not support async disposal. + _cachedFolderStream?.Dispose(); +#pragma warning restore VSTHRD103 + } + _cachedFolderStream = null; + _cachedFolder = null; + } } diff --git a/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs b/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs index dbad5353..cdee70b1 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs @@ -163,4 +163,71 @@ internal partial class ArchiveDatabase pw ); } + + // Cache used to avoid re-decoding a solid folder from scratch for every file it contains. + // Without this, extracting N files from the same solid folder decodes O(N^2) bytes since each + // file's stream was previously created fresh from the folder start and skipped forward. + private CFolder? _cachedFolder; + private Stream? _cachedFolderStream; + private long _cachedFolderStreamPosition; + + /// + /// Returns a stream positioned at bytes into the decompressed + /// contents of . Reuses a cached decoder stream for the folder when + /// possible instead of recreating and re-decoding from the start. + /// + internal Stream GetFolderStream( + Stream stream, + CFolder folder, + IPasswordProvider pw, + long skipSize, + long entrySize + ) + { + if (_cachedFolder == folder && _cachedFolderStream != null) + { + if (skipSize >= _cachedFolderStreamPosition) + { + var delta = skipSize - _cachedFolderStreamPosition; + if (delta > 0) + { + _cachedFolderStream.Skip(delta); + } + // Assume the caller will fully consume the returned entry stream, advancing the + // shared stream by entrySize bytes. + _cachedFolderStreamPosition = skipSize + entrySize; + return _cachedFolderStream; + } + + // Non-sequential (backward) access within the same folder requires restarting. + _cachedFolderStream.Dispose(); + _cachedFolderStream = null; + _cachedFolder = null; + } + else if (_cachedFolderStream != null) + { + _cachedFolderStream.Dispose(); + _cachedFolderStream = null; + _cachedFolder = null; + } + + var newStream = GetFolderStream(stream, folder, pw); + if (skipSize > 0) + { + newStream.Skip(skipSize); + } + + _cachedFolder = folder; + _cachedFolderStream = newStream; + _cachedFolderStreamPosition = skipSize + entrySize; + return newStream; + } + + internal void DisposeCachedFolderStream() + { + _cachedFolderStream?.Dispose(); + _cachedFolderStream = null; + _cachedFolder = null; + _cachedFolderStreamPosition = 0; + } } diff --git a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs index 65bb7503..cfedbe07 100644 --- a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs +++ b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs @@ -44,7 +44,6 @@ internal class SevenZipFilePart : FilePart { return Stream.Null; } - var folderStream = _database.GetFolderStream(_stream, Folder!, _database.PasswordProvider); var firstFileIndex = _database._folderStartFileIndex[_database._folders.IndexOf(Folder!)]; var skipCount = Index - firstFileIndex; @@ -53,11 +52,15 @@ internal class SevenZipFilePart : FilePart { skipSize += _database._files[firstFileIndex + i].Size; } - if (skipSize > 0) - { - folderStream.Skip(skipSize); - } - return new ReadOnlySubStream(folderStream, Header.Size, leaveOpen: false); + + var folderStream = _database.GetFolderStream( + _stream, + Folder!, + _database.PasswordProvider, + skipSize, + Header.Size + ); + return new ReadOnlySubStream(folderStream, Header.Size, leaveOpen: true); } internal override async ValueTask GetCompressedStreamAsync( @@ -68,9 +71,6 @@ internal class SevenZipFilePart : FilePart { return Stream.Null; } - var folderStream = await _database - .GetFolderStreamAsync(_stream, Folder!, _database.PasswordProvider, cancellationToken) - .ConfigureAwait(false); var firstFileIndex = _database._folderStartFileIndex[_database._folders.IndexOf(Folder!)]; var skipCount = Index - firstFileIndex; @@ -79,11 +79,18 @@ internal class SevenZipFilePart : FilePart { skipSize += _database._files[firstFileIndex + i].Size; } - if (skipSize > 0) - { - await folderStream.SkipAsync(skipSize, cancellationToken).ConfigureAwait(false); - } - return new ReadOnlySubStream(folderStream, Header.Size, leaveOpen: false); + + var folderStream = await _database + .GetFolderStreamAsync( + _stream, + Folder!, + _database.PasswordProvider, + skipSize, + Header.Size, + cancellationToken + ) + .ConfigureAwait(false); + return new ReadOnlySubStream(folderStream, Header.Size, leaveOpen: true); } public CompressionType CompressionType diff --git a/src/SharpCompress/IO/BufferedSubStream.Async.cs b/src/SharpCompress/IO/BufferedSubStream.Async.cs index 04c85b07..8312a7d6 100644 --- a/src/SharpCompress/IO/BufferedSubStream.Async.cs +++ b/src/SharpCompress/IO/BufferedSubStream.Async.cs @@ -36,8 +36,10 @@ internal partial class BufferedSubStream BytesLeftToRead -= _cacheLength; } - [Zomp.SyncMethodGenerator.CreateSyncVersion] - public override async Task ReadAsync( + // Fast, synchronous-completion path used when the requested bytes are already sitting in the + // in-memory cache. Avoids the async state-machine / Task allocation overhead incurred by every + // single-byte read the LZMA range decoder issues, without changing buffering/caching semantics. + public override Task ReadAsync( byte[] buffer, int offset, int count, @@ -49,6 +51,24 @@ internal partial class BufferedSubStream count = (int)Length; } + if (count > 0 && _cacheOffset < _cacheLength) + { + count = Math.Min(count, _cacheLength - _cacheOffset); + Buffer.BlockCopy(_cache!, _cacheOffset, buffer, offset, count); + _cacheOffset += count; + return Task.FromResult(count); + } + + return ReadSlowAsync(buffer, offset, count, cancellationToken); + } + + private async Task ReadSlowAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { if (count > 0) { if (_cacheOffset == _cacheLength) @@ -65,7 +85,7 @@ internal partial class BufferedSubStream } #if !LEGACY_DOTNET - public override async ValueTask ReadAsync( + public override ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default ) @@ -76,6 +96,28 @@ internal partial class BufferedSubStream count = (int)Length; } + if (count > 0 && _cacheOffset < _cacheLength) + { + count = Math.Min(count, _cacheLength - _cacheOffset); + _cache!.AsSpan(_cacheOffset, count).CopyTo(buffer.Span); + _cacheOffset += count; + return new ValueTask(count); + } + + return ReadSlowAsync(buffer, cancellationToken); + } + + private async ValueTask ReadSlowAsync( + Memory buffer, + CancellationToken cancellationToken + ) + { + var count = buffer.Length; + if (count > Length) + { + count = (int)Length; + } + if (count > 0) { if (_cacheOffset == _cacheLength) diff --git a/src/SharpCompress/IO/BufferedSubStream.cs b/src/SharpCompress/IO/BufferedSubStream.cs index 0de8822c..08e60e15 100755 --- a/src/SharpCompress/IO/BufferedSubStream.cs +++ b/src/SharpCompress/IO/BufferedSubStream.cs @@ -73,6 +73,28 @@ internal partial class BufferedSubStream : Stream, IStreamStack return _cache![_cacheOffset++]; } + public override int Read(byte[] buffer, int offset, int count) + { + if (count > Length) + { + count = (int)Length; + } + + if (count > 0) + { + if (_cacheOffset == _cacheLength) + { + RefillCache(); + } + + count = Math.Min(count, _cacheLength - _cacheOffset); + Buffer.BlockCopy(_cache!, _cacheOffset, buffer, offset, count); + _cacheOffset += count; + } + + return count; + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException();