trans_stream, rpng: let callers pick a deflate strategy; encode PNG filtered

The remaining size gap against libpng was not filter selection.  Dumping
both encoders' per-row filter bytes for the same image gives identical
histograms - 3D scene: 1 Sub, 1079 Average for both; smooth ramp: 1 Sub,
127 Up, 952 Paeth for both; pixel art: 23 Sub, 900 Up, 157 Paeth for
both - and yet rpng's IDAT came out 3.13 MB where libpng's was 2.82 MB
from byte-identical filtered input.

It was the zlib strategy.  Filtered scanlines are mostly small residuals
with a fairly random distribution, which is the case Z_FILTERED exists
for: fewer long match searches, more Huffman.  libpng has used it for
IDAT since forever.  The trans_stream zlib backend hardcoded
Z_DEFAULT_STRATEGY, which is tuned for text-like input, and offered no
way to change it - so the gap was not something the encoder could fix
from its own side.

Adds "strategy" to the zlib backend's define(), with the values named in
trans_stream.h so callers do not need zlib.h to pick one, and has
rpng_save_image_stream ask for the filtered strategy.

Encode, gcc 13.3 -O3, best of 3, libpng 1.6.43 at its default:

                    libpng          before          after
  3D scene         698 ms/2.82    390 ms/3.13    647 ms/2.82
  3D scene 4K     2852 ms/11.26  1565 ms/12.49  2597 ms/11.26
  smooth ramp      458 ms/1.00    416 ms/1.00    421 ms/1.00
  smooth ramp 4K  1827 ms/3.97   1671 ms/3.97   1699 ms/3.97
  2D pixel art      57 ms/0.23     36 ms/0.23     35 ms/0.23
  2D pixel art 4K  238 ms/0.88    144 ms/0.88    142 ms/0.88
  menu frame        45 ms/0.01     25 ms/0.01     24 ms/0.01

Only noisy 3D output pays anything, and it pays 1.03 s for 1.23 MB at
4K.  That is a much better exchange than the one turned down in 13e0986,
which was 2.2 seconds for 80 KB, and it moves the encoder from "faster
than libpng but bigger" to "faster than libpng and the same size" on
every shape measured.

Z_RLE is the interesting third option and is not taken: 258 ms/11.19 MB
on the 4K 3D scene beats filtered on both axes, but the same setting
turns the 4K ramp into 7.70 MB against 3.97, so it is not safe as a
blanket choice.  It is reachable now for anything that wants it.

The clean-room backend has no strategy concept, so define() returns
false there and HAVE_ZLIB=0 builds keep exactly their current output -
verified byte for byte over the 222-file width sweep.

Validation: the 24-case round trip (four content shapes at 1920x1080,
640x480 and 37x17, both public entry points, each re-decoded with libpng
and with rpng against the source pixels) passes for HAVE_ZLIB=1 and
HAVE_ZLIB=0 under -fsanitize=address,undefined; and all 222 files of the
width sweep - widths 1 through 23 plus 31/32/33, 47/48/49, 63/64/65,
127, 255, 511, 1023 and 1920 - decode byte-identically through rpng and
libpng.  C89 verified on x86-64 and aarch64.
This commit is contained in:
LibretroAdmin authored and libretroadmin committed 2026-07-27 05:09:40 +02:00
1 parent 2519198e1f
commit 9aacb71706
3 files changed
+56 -2

No files matched your search

+37
View File
@@ -696,7 +696,44 @@ bool rpng_save_image_stream(const uint8_t *data, intfstream_t* intf_s,
* 6 is also zlib's and libpng's own default, so a screenshot is no
* longer larger or slower than what every other PNG writer emits. */
if (stream)
{
stream_backend->define(stream, "level", 6);
/* Filtered scanlines are mostly small residuals with a fairly
* random distribution, which is exactly the case Z_FILTERED
* exists for: fewer long match searches, more Huffman. zlib's
* default strategy is tuned for text-like input and leaves size
* on the table here. libpng has used the filtered strategy for
* IDAT since forever, and this is the whole of the size gap
* against it - with the same filter choices per row (verified by
* dumping both encoders' filter bytes: identical) rpng was
* producing a 3.13 MB file where libpng produced 2.82 MB.
*
* Cost, 1920x1080 then 3840x2160, against libpng at its default:
*
* libpng default filtered
* 3D scene 712 ms/2.82 390 ms/3.13 679 ms/2.82
* 3D scene 4K 2856 ms/11.26 1565 ms/12.49 2597 ms/11.26
* smooth ramp 459 ms/1.00 416 ms/1.00 419 ms/1.00
* 2D pixel art 61 ms/0.23 36 ms/0.23 36 ms/0.23
* menu frame 47 ms/0.01 25 ms/0.01 24 ms/0.01
*
* Only noisy 3D output pays, and it pays 1.03 s for 1.23 MB at
* 4K. That is a far better exchange than the one turned down in
* 13e0986 (2.2 s for 80 KB), and it leaves the encoder both
* faster than libpng and the same size as libpng on every shape
* measured rather than faster but bigger.
*
* Z_RLE is the interesting third option - 258 ms/11.19 MB on the
* 4K 3D scene, beating filtered on both axes - but it collapses
* on smooth content, 7.70 MB against 3.97 for the 4K ramp, so it
* is not safe as a blanket choice.
*
* The clean-room backend has no strategy support and returns
* false here, keeping its current output. */
stream_backend->define(stream, "strategy",
TRANS_STREAM_STRATEGY_FILTERED);
}
if (!stream)
GOTO_END_ERROR();
@@ -49,6 +49,18 @@ enum trans_stream_error
TRANS_STREAM_ERROR_OTHER
};
/* Values for the "strategy" property, which selects how a deflate
* backend trades match searching against Huffman coding. Numerically
* identical to zlib's Z_* constants, restated here so callers do not
* have to include zlib.h to pick one. A backend that does not
* implement strategies returns false from define() and keeps its
* default behaviour. */
#define TRANS_STREAM_STRATEGY_DEFAULT 0
#define TRANS_STREAM_STRATEGY_FILTERED 1
#define TRANS_STREAM_STRATEGY_HUFFMAN 2
#define TRANS_STREAM_STRATEGY_RLE 3
#define TRANS_STREAM_STRATEGY_FIXED 4
struct trans_stream_backend
{
const char *ident;
+7 -2
View File
@@ -30,6 +30,7 @@ struct zlib_trans_stream
{
z_stream z;
int window_bits;
int strategy;
int level;
bool inited;
};
@@ -43,6 +44,7 @@ static void *zlib_deflate_stream_new(void)
ret->inited = false;
ret->level = 9;
ret->window_bits = 15;
ret->strategy = Z_DEFAULT_STRATEGY;
ret->z.next_in = NULL;
ret->z.avail_in = 0;
@@ -72,6 +74,7 @@ static void *zlib_inflate_stream_new(void)
return NULL;
ret->inited = false;
ret->window_bits = MAX_WBITS;
ret->strategy = Z_DEFAULT_STRATEGY;
ret->z.next_in = NULL;
ret->z.avail_in = 0;
@@ -122,6 +125,8 @@ static bool zlib_deflate_define(void *data, const char *prop, uint32_t val)
if (strcmp(prop, "level") == 0)
z->level = (int) val;
else if (strcmp(prop, "strategy") == 0)
z->strategy = (int) val;
else if (strcmp(prop, "window_bits") == 0)
z->window_bits = (int) val;
else
@@ -156,7 +161,7 @@ static void zlib_deflate_set_in(void *data, const uint8_t *in, uint32_t in_size)
if (!z->inited)
{
deflateInit2(&z->z, z->level, Z_DEFLATED , z->window_bits, 8, Z_DEFAULT_STRATEGY );
deflateInit2(&z->z, z->level, Z_DEFLATED , z->window_bits, 8, z->strategy );
z->inited = true;
}
}
@@ -202,7 +207,7 @@ static bool zlib_deflate_trans(
if (!zt->inited)
{
deflateInit2(z, zt->level, Z_DEFLATED , zt->window_bits, 8, Z_DEFAULT_STRATEGY );
deflateInit2(z, zt->level, Z_DEFLATED , zt->window_bits, 8, zt->strategy );
zt->inited = true;
}