Browse Source

Grow AV1 range output at native flush boundaries

pull/2633/head
James Jackson-South 4 weeks ago
parent
commit
ef8b1a8237
  1. 52
      HEIF_IMPLEMENTATION_PLAN.md
  2. 4
      src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs
  3. 48
      src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs
  4. 1
      src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs
  5. 114
      tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs

52
HEIF_IMPLEMENTATION_PLAN.md

@ -283,6 +283,32 @@ Frame/block RD and decoder filter follow-up after `aa2ecf690`:
superblock modifier from the range of subblock variances (`partition_search.c:5721-5734`). superblock modifier from the range of subblock variances (`partition_search.c:5721-5734`).
`Av1IntraSuperblockEncoder.ModeDecision.cs:172-176` uses only the segment-zero base quantizer and intra flag. `Av1IntraSuperblockEncoder.ModeDecision.cs:172-176` uses only the segment-zero base quantizer and intra flag.
These missing policies remain architectural deviations; no isolated multiplier adjustment was introduced. These missing policies remain architectural deviations; no isolated multiplier adjustment was introduced.
- The default `disable_trellis_quant = 3` (`av1/av1_cx_iface.c:291`) means
`NO_ESTIMATE_YRD_TRELLIS_OPT`, not final-pass-only optimization (`speed_features.c:2493-2513`).
`encodemb.h:157-162` and `tx_search.c:2084-2085,2216-2229` still allow optimization during transform
candidate evaluation under that default. Control value 2 selects final-pass-only behavior. A winner-only
optimizer would therefore leave the default production path incomplete.
- The complete optimizer traversal at `av1/encoder/txb_rdopt.c:18-560` and its cost/dequantization helpers
(`txb_rdopt_utils.h:39-204`) were followed through level lowering, EOB replacement, empty-transform selection,
signed DC handling, plane/precision/tuning scaling, and joint rate/EOB/context publication. It retains only
three nonzero positions for the EOB phase, then changes traversal after that phase's bound is exceeded.
That bound belongs to this complete traversal; it is not a general candidate-pruning threshold.
Managed `Av1SymbolEncoder.cs:1183-1334` evaluates an unchanged coefficient vector and
`Av1TransformBlockEncoder.cs:1175-1225` stops at fast quantization. Existing scratch can support parts of the
arithmetic, but cost-state policy, evaluation stages, mutation, and reconstruction must be integrated together.
- The caller policy also changes quantization, not only the decision to invoke trellis:
`tx_search.c:1964-2000,2147-2156,2216-2229` derives the MSE/SATD gates from the active evaluation stage,
switches between fast and regular quantization, then optimizes before distortion/reconstruction.
Threshold tables and default/mode/winner selection live in `speed_features.c:76-100` and `rd.h:353-381`.
Managed candidate reconstruction currently happens before `GetCoefficientCost`
(`Av1TransformBlockEncoder.cs:187-251`, `Av1IntraSuperblockEncoder.ModeDecision.cs:2494-2550`).
Adding optimization to that later cost call would reconstruct twice or leave candidate pixels stale.
- The optional residual border policy is selected in `encoder.c:4559-4568`: GOOD mode, objective delta-Q,
TPL enabled, no AQ/segmentation/ROI/QP sweep/ducky path, and sharpness other than three.
`encodemb.c:80-173` fills outside-visible residuals using a whole-block mean, per-axis mean, or zero,
depending on transform type. Thus border residual reuse across transform candidates is conditional.
This policy is not an unconditional replacement for coded-edge replication, and is not enabled for ALLINTRA.
No isolated border-padding rule was added; its configuration and candidate integration remain open.
- Transform pixel-error normalization and the modeled-rate skip comparison were also traced through - Transform pixel-error normalization and the modeled-rate skip comparison were also traced through
`Av1TransformBlockEncoder.cs:577-590`, `Av1RateDistortion.cs:259-307`, native `Av1TransformBlockEncoder.cs:577-590`, `Av1RateDistortion.cs:259-307`, native
`av1/encoder/model_rd.h:70-106,162-199`, and `av1/encoder/tx_search.c:979-1051`. `av1/encoder/model_rd.h:70-106,162-199`, and `av1/encoder/tx_search.c:979-1051`.
@ -294,6 +320,32 @@ Frame/block RD and decoder filter follow-up after `aa2ecf690`:
retains bottom lines for its worker-capable traversal. This inspection establishes no decoder-wide or SIMD retains bottom lines for its worker-capable traversal. This inspection establishes no decoder-wide or SIMD
completeness claim. Decoder CDEF storage remains an operation-scoped owner, not native reusable worker state. completeness claim. Decoder CDEF storage remains an operation-scoped owner, not native reusable worker state.
Range-writer output-capacity correction, verified after `93aba785f`:
- `Av1SymbolWriter.cs:213-214,339` before correction sliced a fixed initial allocation for finalization
and eight-byte flushes. Reference `aom_dsp/entenc.c:78-91,270-285` grows capacity when either needs
more room. The packet estimate in `Av1FrameEncoder.cs:544-563` does not replace that range-coder policy.
This is a demonstrated writer-capacity deviation; no production frame overflowing that estimate was established.
- The existing owner now grows at those two boundaries. Word flushes double the current tile capacity and add
eight bytes; finalization reserves its exact terminating length. Reallocation preserves finalized preceding
tiles and the current completed prefix, including bytes that can receive a backward carry. Pending bits stay
in the range state. The old owner is returned only after successful allocation/copy, and reset reuses capacity.
Existing frame aggregation remains; this does not complete native deferred-packing or worker ownership parity.
- Before correction, the zero-capacity consecutive-tile regression failed in `Normalize` with
`ArgumentOutOfRangeException` (`writer-growth-red.trx`); VSTest stopped on that first failure.
Five small initial capacities now preserve three consecutive tiles byte-for-byte against sufficient-capacity
encoding, with CDF adaptation enabled/disabled. Existing native carry assertions cover two additional
finalization-growth capacities. Allocation limits independently exercise failure at both growth boundaries,
and allocation identities verify every successful owner is returned exactly once.
- Final Release .NET 11 incremental build: zero errors and warnings; preceding test compilation:
1,009 existing warnings. Roslynk reports zero compiler errors. Serialized Visual Studio VSTest passes
2,292/2,292 entropy, intra-superblock, encoder-frame, and HEIF encoder cases in 26.9726 seconds
(`writer-growth-final.trx`), including the twelve focused writer cases.
- Current optimized libaom decoding of the regenerated 23 palette, eight partition, and twelve color-sequence
streams matches all 38,973 samples exactly: maximum error 0, zero samples exceeding one.
These are bounded same-bitstream checks, not separate-encoder parity or a timing/quality improvement.
No benchmark was run. All temporary native comparison output and test reports remain excluded from commits.
Restoration processing-unit correction: Restoration processing-unit correction:
- Before correction, `Av1LoopRestorationDecoder.cs:147-166,309-358` sized its bordered source, Wiener - Before correction, `Av1LoopRestorationDecoder.cs:147-166,309-358` sized its bordered source, Wiener

4
src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolEncoder.cs

@ -222,7 +222,7 @@ internal sealed class Av1SymbolEncoder : IDisposable
/// Initializes a new instance of the <see cref="Av1SymbolEncoder"/> class with reusable tile state. /// Initializes a new instance of the <see cref="Av1SymbolEncoder"/> class with reusable tile state.
/// </summary> /// </summary>
/// <param name="configuration">The configuration providing output and temporary memory.</param> /// <param name="configuration">The configuration providing output and temporary memory.</param>
/// <param name="bufferLength">The complete fixed output allocation length in bytes.</param> /// <param name="bufferLength">The initial output capacity in bytes.</param>
/// <param name="qIndex">The frame base quantizer index.</param> /// <param name="qIndex">The frame base quantizer index.</param>
/// <param name="updateCdf">A value indicating whether encoded symbols adapt their tile distributions.</param> /// <param name="updateCdf">A value indicating whether encoded symbols adapt their tile distributions.</param>
public Av1SymbolEncoder(Configuration configuration, int bufferLength, int qIndex, bool updateCdf) public Av1SymbolEncoder(Configuration configuration, int bufferLength, int qIndex, bool updateCdf)
@ -1527,7 +1527,7 @@ internal sealed class Av1SymbolEncoder : IDisposable
/// Exposes a prefix containing every consecutively encoded tile without copying their bytes. /// Exposes a prefix containing every consecutively encoded tile without copying their bytes.
/// </summary> /// </summary>
/// <param name="length">The number of bytes in the prefix.</param> /// <param name="length">The number of bytes in the prefix.</param>
/// <returns>The encoded prefix, valid until this encoder is reset to offset zero or disposed.</returns> /// <returns>The encoded prefix, valid until this encoder is reset or disposed.</returns>
public ReadOnlyMemory<byte> GetOutput(int length) public ReadOnlyMemory<byte> GetOutput(int length)
=> this.writer.GetOutput(length); => this.writer.GetOutput(length);

48
src/ImageSharp/Formats/Heif/Av1/Entropy/Av1SymbolWriter.cs

@ -46,14 +46,14 @@ internal sealed class Av1SymbolWriter : IDisposable
private readonly Configuration configuration; private readonly Configuration configuration;
/// <summary> /// <summary>
/// The owner of the fixed output buffer shared by consecutively encoded tiles. /// The owner of the output buffer shared by consecutively encoded tiles.
/// </summary> /// </summary>
private readonly IMemoryOwner<byte> bufferOwner; private IMemoryOwner<byte> bufferOwner;
/// <summary> /// <summary>
/// The complete requested output allocation, including every consecutively encoded tile. /// The complete requested output allocation, including every consecutively encoded tile.
/// </summary> /// </summary>
private readonly Memory<byte> outputBuffer; private Memory<byte> outputBuffer;
/// <summary> /// <summary>
/// The requested output range, excluding any excess capacity returned by a pooling allocator. /// The requested output range, excluding any excess capacity returned by a pooling allocator.
@ -71,10 +71,10 @@ internal sealed class Av1SymbolWriter : IDisposable
private int position; private int position;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Av1SymbolWriter"/> class with a bounded output size. /// Initializes a new instance of the <see cref="Av1SymbolWriter"/> class.
/// </summary> /// </summary>
/// <param name="configuration">The configuration that supplies output allocation.</param> /// <param name="configuration">The configuration that supplies output allocation.</param>
/// <param name="bufferLength">The complete fixed output allocation length in bytes.</param> /// <param name="bufferLength">The initial output capacity in bytes.</param>
/// <param name="updateCdf">A value indicating whether encoded symbols adapt their distributions.</param> /// <param name="updateCdf">A value indicating whether encoded symbols adapt their distributions.</param>
public Av1SymbolWriter(Configuration configuration, int bufferLength, bool updateCdf) public Av1SymbolWriter(Configuration configuration, int bufferLength, bool updateCdf)
{ {
@ -86,7 +86,7 @@ internal sealed class Av1SymbolWriter : IDisposable
} }
/// <summary> /// <summary>
/// Restores the initial range-coder state while retaining the bounded output allocation. /// Restores the initial range-coder state and begins a new output sequence.
/// </summary> /// </summary>
public void Reset() => this.Reset(0); public void Reset() => this.Reset(0);
@ -192,7 +192,7 @@ internal sealed class Av1SymbolWriter : IDisposable
/// Exposes a prefix containing consecutively encoded tiles without copying their bytes. /// Exposes a prefix containing consecutively encoded tiles without copying their bytes.
/// </summary> /// </summary>
/// <param name="length">The number of bytes in the prefix.</param> /// <param name="length">The number of bytes in the prefix.</param>
/// <returns>The encoded prefix, valid until this writer is reset to offset zero or disposed.</returns> /// <returns>The encoded prefix, valid until this writer is reset or disposed.</returns>
public ReadOnlyMemory<byte> GetOutput(int length) => this.outputBuffer[..length]; public ReadOnlyMemory<byte> GetOutput(int length) => this.outputBuffer[..length];
/// <summary> /// <summary>
@ -211,6 +211,12 @@ internal sealed class Av1SymbolWriter : IDisposable
ulong e = ((l + m) & ~m) | (m + 1); ulong e = ((l + m) & ~m) | (m + 1);
s += c; s += c;
int pendingByteCount = Math.Max((s + 7) >> 3, 0); int pendingByteCount = Math.Max((s + 7) >> 3, 0);
if (pos + pendingByteCount > this.buffer.Length)
{
// Finalization needs only the terminating bytes; ordinary word flushes reserve their own headroom.
this.ResizeBuffer(pos + pendingByteCount);
}
Span<byte> buffer = this.buffer.Span[..(pos + pendingByteCount)]; Span<byte> buffer = this.buffer.Span[..(pos + pendingByteCount)];
if (s > 0) if (s > 0)
{ {
@ -336,6 +342,13 @@ internal sealed class Av1SymbolWriter : IDisposable
// bytes together while preserving one carry bit. // bytes together while preserving one carry bit.
if (s >= 40) if (s >= 40)
{ {
if (this.position + sizeof(ulong) > this.buffer.Length)
{
// A word store touches eight bytes even when fewer become logical output. Double the current
// tile capacity and add one word, matching the range coder's amortized growth from an empty buffer.
this.ResizeBuffer(checked((2 * this.buffer.Length) + sizeof(ulong)));
}
Span<byte> buffer = this.buffer.Span[..(this.position + sizeof(ulong))]; Span<byte> buffer = this.buffer.Span[..(this.position + sizeof(ulong))];
int readyByteCount = (s >> 3) + 1; int readyByteCount = (s >> 3) + 1;
c += 24 - (readyByteCount << 3); c += 24 - (readyByteCount << 3);
@ -365,6 +378,27 @@ internal sealed class Av1SymbolWriter : IDisposable
this.cnt = s; this.cnt = s;
} }
/// <summary>
/// Replaces the output owner while retaining finalized tiles and the current tile's completed bytes.
/// </summary>
/// <param name="tileCapacity">The required capacity starting at the current tile's output offset.</param>
private void ResizeBuffer(int tileCapacity)
{
int outputOffset = this.outputBuffer.Length - this.buffer.Length;
int capacity = checked(outputOffset + tileCapacity);
IMemoryOwner<byte> replacement = this.configuration.MemoryAllocator.Allocate<byte>(capacity);
Memory<byte> replacementBuffer = replacement.Memory[..capacity];
// Previous tile bytes remain part of the frame payload. The current tile's completed prefix also carries
// backward into earlier bytes, so preserve that prefix before returning the old owner. Pending bits stay
// in low/cnt and need no copy. If allocation fails, the original owner remains available for disposal.
this.outputBuffer.Span[..(outputOffset + this.position)].CopyTo(replacementBuffer.Span);
this.bufferOwner.Dispose();
this.bufferOwner = replacement;
this.outputBuffer = replacementBuffer;
this.buffer = replacementBuffer[outputOffset..];
}
/// <summary> /// <summary>
/// Adds a carry to the completed output prefix. /// Adds a carry to the completed output prefix.
/// </summary> /// </summary>

1
src/ImageSharp/Formats/Heif/Av1/Pipeline/Av1FrameEncoder.cs

@ -545,6 +545,7 @@ internal static class Av1FrameEncoder
{ {
// Libaom reserves 2.5 times the 32-sample-aligned native input for an all-intra output packet. // Libaom reserves 2.5 times the 32-sample-aligned native input for an all-intra output packet.
// Counting the active planes directly retains that headroom without charging monochrome for unused chroma. // Counting the active planes directly retains that headroom without charging monochrome for unused chroma.
// This is an initial estimate: the range writer grows if encoded syntax exceeds its remaining capacity.
int alignedWidth = Av1Math.AlignPowerOf2(width, OutputAlignmentLog2); int alignedWidth = Av1Math.AlignPowerOf2(width, OutputAlignmentLog2);
int alignedHeight = Av1Math.AlignPowerOf2(height, OutputAlignmentLog2); int alignedHeight = Av1Math.AlignPowerOf2(height, OutputAlignmentLog2);
int subsamplingX = colorConfig.SubSamplingX ? 1 : 0; int subsamplingX = colorConfig.SubSamplingX ? 1 : 0;

114
tests/ImageSharp.Tests/Formats/Heif/Av1/Av1EntropyTests.cs

@ -931,10 +931,13 @@ public class Av1EntropyTests
int expected) int expected)
=> Assert.Equal(expected, Av1RateDistortion.GetInterFrameRateMultiplier(qIndex, (Av1BitDepth)bitDepth)); => Assert.Equal(expected, Av1RateDistortion.GetInterFrameRateMultiplier(qIndex, (Av1BitDepth)bitDepth));
[Fact] [Theory]
public void SymbolWriterMatchesCurrentLibaomCarryRegression() [InlineData(0)]
[InlineData(1)]
[InlineData(ShortSyntaxBufferLength)]
public void SymbolWriterMatchesCurrentLibaomCarryRegression(int initialCapacity)
{ {
using Av1SymbolWriter writer = new(Configuration.Default, ShortSyntaxBufferLength, updateCdf: false); using Av1SymbolWriter writer = new(Configuration.Default, initialCapacity, updateCdf: false);
writer.WriteBoolean(false, 16_384); writer.WriteBoolean(false, 16_384);
writer.WriteBoolean(false, 16_384); writer.WriteBoolean(false, 16_384);
writer.WriteBoolean(true, 512); writer.WriteBoolean(true, 512);
@ -968,6 +971,105 @@ public class Av1EntropyTests
Assert.Equal(allocation.HashCodeOfBuffer, returned.HashCodeOfBuffer); Assert.Equal(allocation.HashCodeOfBuffer, returned.HashCodeOfBuffer);
} }
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(7, false)]
[InlineData(8, true)]
[InlineData(17, true)]
public void SymbolWriterGrowthPreservesConsecutiveTiles(int initialCapacity, bool updateCdf)
{
TestMemoryAllocator allocator = new();
allocator.EnableNonThreadSafeLogging();
Configuration configuration = Configuration.Default.Clone();
configuration.MemoryAllocator = allocator;
using (Av1SymbolWriter writer = new(configuration, initialCapacity, updateCdf))
using (Av1SymbolWriter expected = new(Configuration.Default, 8192, updateCdf))
{
int outputLength = 0;
for (int tile = 0; tile < 3; tile++)
{
writer.Reset(outputLength);
expected.Reset(outputLength);
Av1Distribution distribution = new(100, 16000, 32000);
Av1Distribution expectedDistribution = new(100, 16000, 32000);
for (int index = 0; index < 257; index++)
{
// Small intervals provoke carries while literals cross repeated word-flush boundaries.
int symbol = (index + tile) & 3;
uint literal = (uint)((index * 73) + tile);
writer.WriteSymbol(symbol, distribution);
writer.WriteLiteral(literal, 8);
expected.WriteSymbol(symbol, expectedDistribution);
expected.WriteLiteral(literal, 8);
}
ReadOnlyMemory<byte> actualTile = writer.Exit(out int length);
ReadOnlyMemory<byte> expectedTile = expected.Exit(out int expectedLength);
Assert.Equal(expectedLength, length);
Assert.True(expectedTile.Span.SequenceEqual(actualTile.Span));
outputLength += length;
// Growth in a later tile must preserve all earlier finalized tile bytes too.
Assert.True(expected.GetOutput(outputLength).Span.SequenceEqual(writer.GetOutput(outputLength).Span));
}
Assert.True(allocator.AllocationLog.Count > 1);
Assert.Equal(allocator.AllocationLog.Count - 1, allocator.ReturnLog.Count);
int allocations = allocator.AllocationLog.Count;
writer.Reset();
writer.WriteLiteral(false);
_ = writer.Exit(out _);
Assert.Equal(allocations, allocator.AllocationLog.Count);
}
Assert.Equal(
allocator.AllocationLog.Select(x => x.AllocationId).Order(),
allocator.ReturnLog.Select(x => x.AllocationId).Order());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SymbolWriterGrowthFailureRetainsItsOwner(bool failDuringFinalization)
{
int initialCapacity = failDuringFinalization ? 1 : 8;
OutputLimitedAllocator allocator = new(initialCapacity);
allocator.EnableNonThreadSafeLogging();
Configuration configuration = Configuration.Default.Clone();
configuration.MemoryAllocator = allocator;
using (Av1SymbolWriter writer = new(configuration, initialCapacity, updateCdf: false))
{
Assert.Throws<InvalidMemoryOperationException>(() =>
{
if (failDuringFinalization)
{
writer.WriteBoolean(false, 16_384);
writer.WriteBoolean(false, 16_384);
writer.WriteBoolean(true, 512);
writer.WriteBoolean(false, 8_192);
_ = writer.Exit(out _);
}
else
{
for (int index = 0; index < 32; index++)
{
writer.WriteLiteral((uint)index, 8);
}
}
});
Assert.Single(allocator.AllocationLog);
Assert.Empty(allocator.ReturnLog);
}
Assert.Equal(
Assert.Single(allocator.AllocationLog).AllocationId,
Assert.Single(allocator.ReturnLog).AllocationId);
}
[Fact] [Fact]
public void SymbolWriterResetReusesExistingOutputAllocation() public void SymbolWriterResetReusesExistingOutputAllocation()
{ {
@ -2268,6 +2370,12 @@ public class Av1EntropyTests
return result; return result;
} }
// Exercise the allocator's actual contiguous-buffer boundary while retaining the existing owner log.
private sealed class OutputLimitedAllocator : TestMemoryAllocator
{
public OutputLimitedAllocator(int limit) => this.SingleBufferAllocationLimitBytes = limit;
}
public static TheoryData<int, bool> GetInterTransformTypeData() public static TheoryData<int, bool> GetInterTransformTypeData()
{ {
TheoryData<int, bool> result = []; TheoryData<int, bool> result = [];

Loading…
Cancel
Save