/*
 * Still like 'pnmscale'
 *  やっぱり pnmscale が好き
 *   by uratan! 2024.2.4
 */

はじめに

Netpbm の画像リサイズツールである pnmscale、 長らくお世話になって来ましたが 最近のバージョンでは PAM 対応版である pamscale にその役目が引き継がれて pnmscale 自体はディスコンになってしまいました。
pamscale を評価してみたところ、当然ながら大枠では pnmscale と同等(*1)なのですが、これまた当然ながら厳密に同じ 画像バイナリにはなりません。
(*1) pamscale では標準で輝度のガンマ補正が加わるので、 単純に pnmscale を置き換える場合には -linear オプションが必要です。

今回 家庭内サーバーのバージョンアップをもくろみ、 その結果 Netpbm 一式も新しいバージョンとなり pnmscale がない環境にならざるを得なくなりそうなんですが、 指に馴染んだ この pnmscale、同じ画像バイナリを吐くものを なんとか用意できないか?
ソースも公開されているし 簡単に持って行けるだろう と簡単に考えていたのですが、 そうは問屋が卸さなかったので その顛末を ここに記録しておきます。
(画像の一致・不一致は基本 目視確認するしかないので、 過去の画像処理を再現確認する際に md5 sum の一致で済ませられると とても楽になるのです)

以下、まずは FreeBSD 10.2-RELEASE(i386) 内で閉じた内容になります。 古くてすいません。
% uname -a
FreeBSD silver 10.2-RELEASE FreeBSD 10.2-RELEASE #1:
 Sun Mar 12 09:07:49 JST 2017
  uratan@silver:/usr/src/sys/i386/compile/OXYGEN  i386

% cc --version
FreeBSD clang version 3.4.1 (tags/RELEASE_34/dot1-final 208032) 20140512
Target: i386-unknown-freebsd10.2
Thread model: posix

再現したい package版 pnmscale の手がかりは以下の通り。
% pkg info | fgrep netpbm
netpbm-10.35.97      Toolkit for conversion of images between different formats

% /usr/local/bin/pnmscale --version
pnmscale: Using libnetpbm from Netpbm Version: Netpbm 10.35.97
pnmscale: Compiled Thu Jan  7 01:23:27 UTC 2016 by user "root"
pnmscale: BSD defined
pnmscale: RGB_ENV='RGBDEF'
pnmscale: RGBENV= 'RGBDEF' (env vbl is unset)

% file /usr/local/bin/pnmscale
/usr/local/bin/pnmscale: ELF 32-bit LSB executable, Intel 80386,
 version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1,
  for FreeBSD 10.1, stripped

% ls -l /usr/local/bin/pnmscale
-r-xr-xr-x  1 root  wheel  14624 Jan  7  2016 /usr/local/bin/pnmscale*

Netpbm-10.35.97 の pnmscale.c のソースがこちら→ pnmscale.c-10.35.97.txt
(... スタイルはアレですが これなかなか凄いコードと思います)

package版 pnmscaleが再現できない!

「ports で make すれば それでおしまいやろ?」

... FreeBSD 10.2R install image の ports は netpbm-10.35.96.tgz ベースで version が異なります。 10.3R install image では netpbm-10.35.97.tgz ベースになってますが 現 家庭内サーバー をセットアップした頃の途中状況は今では分かりません。
そもそも 上記 file コマンドの出力の通り、package版 pnmscale は FreeBSD 10.1 で make されてるようです…???。 (実際には いろいろ確認したのですが ここでは省略)

「Netpbm の 10.35.97 拾ってきて ./configure; make で問題ないんちゃう?」

... やってみましたが以下の通り 同じ画像バイナリ出力にはなりませんでした。 そもそも pnmscale のサイズが明らかに違いますし、 -verbose 出力を見る感じ、少なくとも演算精度の違いはありそうです。
% tar xzf /..../netpbm-10.35.97.tgz
% cd netpbm-10.35.97/
% ./configure                <-- 選択は すべて default で [ENTER] 連打で
% cd editor/
% gmake pnmscale

% setenv LD_LIBRARY_PATH "$PWD/../lib"

% ./pnmscale --version
pnmscale: Using libnetpbm from Netpbm Version: Netpbm 10.35.97
pnmscale: Compiled Sun Feb  4 19:32:59 JST 2024 by user "uratan"
pnmscale: BSD defined
pnmscale: RGB_ENV='RGBDEF'
pnmscale: RGBENV= 'RGBDEF' (env vbl is unset)

% djpeg some.jpg | /usr/local/bin/pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000000 of right column stretched due to arithmetic imprecision
5077595b1fea606689f109a53735bce8

% djpeg some.jpg | ./pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000032 of right column stretched due to arithmetic imprecision
pnmscale: 0.000038 of bottom row stretched due to arithmetic imprecision
d96baec7bd5027b79a1be7237e1a9fd8

% file ./pnmscale
./pnmscale: ELF 32-bit LSB executable, Intel 80386,
 version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1,
  for FreeBSD 10.2, not stripped

% strip ./pnmscale
% ls -l ./pnmscale /usr/local/bin/pnmscale
-rwxr-xr-x  1 uratan  nobody  21252 Feb  4 19:35 ./pnmscale*
-r-xr-xr-x  1 root    wheel   14624 Jan  7  2016 /usr/local/bin/pnmscale*

最適化オプションが欠けてます!

package版 pnmscale のコードサイズが明らかに小さいことから なんらかの最適化がかけられていると考え、追いかけたところ -O2 がかかっているような感触です。
% echo "CFLAGS=-O2" >> ../Makefile.config

% rm pnmscale.o
% gmake pnmscale

% strip ./pnmscale
% ls -l ./pnmscale /usr/local/bin/pnmscale
-rwxr-xr-x  1 uratan  nobody  14624 Feb  4 19:37 ./pnmscale*
-r-xr-xr-x  1 root    wheel   14624 Jan  7  2016 /usr/local/bin/pnmscale*
(../lib/ 側が -O0 のままになりますが 共有ライブラリ方式で pnmscale 側には含まれませんので 問題ないでしょう)

あらら、同じ画像バイナリを生成する状況には意外とあっさり至りました。
(しかし最適化有り無しで浮動少数演算の結果が違うって いいのか?)
% djpeg some.jpg | /usr/local/bin/pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000000 of right column stretched due to arithmetic imprecision
5077595b1fea606689f109a53735bce8

% djpeg some.jpg | ./pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000000 of right column stretched due to arithmetic imprecision
5077595b1fea606689f109a53735bce8

実行ファイルのコードも完全一致! までは行きませんでしたが、 function は再現できていると考えていいでしょう。
% objdump -d /usr/local/bin/pnmscale > wod0
% objdump -d ./pnmscale > wod1

% diff wod0 wod1 | wc -l
      32

% diff wod0 wod1 | more      <-- 一部メモリ割り付けがちょっと違うだけみたい

ちなみに、FreeBSD 10.2R installe image の ports にも 10.3R の ports にも -O2 を付加する記述は見当たりません。 ports に含まれる files/patch-* のファイル自体の timestamp は リリース日近傍ですが、 中に記録されている差分元ファイルの timestamp が やけに古いので、 package版は ports とは独立に作成されたように思われます。 (patch しなくても make できてるし)

float が勝手に double に昇格されてます!

package版 pnmscale の処理自体は再現できましたが、 ただ これではコンパイラが変わったら また変わる可能性があり、 コンパイラ任せである状況は変わりません。
-O2 で いったい何が起きているのか?
というわけで…、objdump も出ちゃったことだし、そう、 読みましたよ、アセンブラコードを。まあ元 C ソースがあるところで 読むだけだからパズルみたいなもんでしたけど…。

というところで、これ↓で package版 pnmscale の処理が C レベルで再現記述できたはずです。
patch-pnmscale.c.txt
最適化なし -O0 でも同じ画像バイナリになります。
% patch < patch-pnmscale.c.txt
% echo "CFLAGS=-O0" >> ../Makefile.config
% rm pnmscale.o
% gmake pnmscale

% strip ./pnmscale
% ls -l ./pnmscale /usr/local/bin/pnmscale
-rwxr-xr-x  1 uratan  nobody  21972 Feb  4 19:43 ./pnmscale*
-r-xr-xr-x  1 root    wheel   14624 Jan  7  2016 /usr/local/bin/pnmscale*

% djpeg some.jpg | /usr/local/bin/pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000000 of right column stretched due to arithmetic imprecision
5077595b1fea606689f109a53735bce8

% djpeg some.jpg | ./pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000000 of right column stretched due to arithmetic imprecision
5077595b1fea606689f109a53735bce8

結論的には、pnmscale.c 内で float 宣言されている変数(*2)を、勝手に double/倍精度と扱ってましたね。これは i386 の FPU が倍精度なので、 最適化により変数をレジスタ割り付けすると自動的にそうなってしまうのは まあ許そう。
(*2) pnmscale.c では 変数は基本 float です。(double も一部あり)

ただ関数がすべてその場展開されてて 関数の引数もローカル変数に成り下がり 関数の引数まで double に昇格している形になっているのは なんだかなぁ? でしたね。

あと そうやって倍精度扱いしておきながら、レジスタ割り付けした変数を どうしてもメモリに退避しなければいけない場合に float/単精度で済ませている(*3)部分が 2ヶ所ありまして、 まあ元指定が float ですし 所詮コンパイラの最適化だからしょうがなくはあるのですが、 なんか中途半端な印象です。 (r,g,b セットのうち b だけ、つーあたりがなんとも…)
(*3) メモリ上でも ちゃんと double になってる変数もあります、 fracrowtofill とか
  持ち上げといて落としてるのが この 2ヶ所 (patch-pnmscale.c.txt より抜粋)

***************
*** 342,347 ****
--- 343,349 ----
                  r += fraccolleft * PPM_GETR(inputxelrow[col]);
                  g += fraccolleft * PPM_GETG(inputxelrow[col]);
                  b += fraccolleft * PPM_GETB(inputxelrow[col]);
+ b = (float)b;
                  break;
                      
              default:
***************
*** 593,598 ****
--- 595,601 ----
                      fracrowtofill = 0.0;
                  }
              }
+ rowsleft = (float)rowsleft;
              makeRow(vertScaledRow, rs, gs, bs, cols, newmaxval, format);
              zeroAccum(cols, format, rs, gs, bs);
              fracrowtofill = 1.0;
***************

ホントに記述できてるかな?

処理が過不足無く C レベルで記述できているか?、を 次世代 家庭内サーバーの候補として 評価中の FreeBSD 14.0R amd64 環境に持っていって確認します。 実行ファイルの長さが やや大きいですが そんなもんなのでしょう。
% uname -a
FreeBSD oxygen 14.0-RELEASE FreeBSD 14.0-RELEASE #0 releng/14.0-n265380-f9716eee8ab4:
 Fri Nov 10 05:57:23 UTC 2023
  root@releng1.nyi.freebsd.org:/usr/obj/usr/src/amd64.amd64/sys/GENERIC amd64

% cc --version
FreeBSD clang version 16.0.6 (https://github.com/llvm/llvm-project.git
 llvmorg-16.0.6-0-g7cbf1a259152)
Target: x86_64-unknown-freebsd14.0
Thread model: posix
InstalledDir: /usr/bin

% tar xzf /..../netpbm-10.35.97.tgz
% cd netpbm-10.35.97/
% ./configure                <-- 選択は すべて default で [ENTER] 連打で

% cd editor/
% patch < patch-pnmscale.c.txt
% echo "CFLAGS=-O2" >> ../Makefile.config
% gmake pnmscale

% file ./pnmscale
./pnmscale: ELF 64-bit LSB executable, x86-64,
 version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1,
  for FreeBSD 14.0 (1400097), FreeBSD-style, with debug_info, not stripped

% strip ./pnmscale
% ls -l ./pnmscale
-rwxr-xr-x  1 uratan nobody 15856 Feb  4 19:55 ./pnmscale*

% setenv LD_LIBRARY_PATH "$PWD/../lib"

% ./pnmscale --version
pnmscale: Using libnetpbm from Netpbm Version: Netpbm 10.35.97
pnmscale: Compiled Sun Feb  4 19:54:09 JST 2024 by user "uratan"
pnmscale: BSD defined
pnmscale: RGB_ENV='RGBDEF'
pnmscale: RGBENV= 'RGBDEF' (env vbl is unset)

% djpeg some.jpg | ./pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000000 of right column stretched due to arithmetic imprecision
5077595b1fea606689f109a53735bce8

% echo "CFLAGS=-O0" >> ../Makefile.config
% rm pnmscale.o
% gmake pnmscale

% strip ./pnmscale
% ls -l ./pnmscale
-rwxr-xr-x  1 uratan nobody 18296 Feb  4 19:58 ./pnmscale*

% djpeg some.jpg | ./pnmscale -v -h 1600 | md5
pnmscale: Scaling by 0.833084 horizontally to 1113 columns.
pnmscale: Scaling by 0.833333 vertically to 1600 rows.
pnmscale: 0.000000 of right column stretched due to arithmetic imprecision
5077595b1fea606689f109a53735bce8
-O2 でも -O0 でも同じ画像バイナリになりました。 これで めでたし めでたし のはず…。

最後に

アセンブラの読み下しパズルに興味のある方のために…
pnmscale.S.a18.txt   (cc -S -O2 で出力した asm ソースに mark-up)
##-NNN が元 C ソースの NNN行目です。
行末コメントは
  | #hs# が horizontal_scale() 部分、
  | # が scaleWithMixing() 部分、
  | #xM# が scaleWithoutMixing() 部分、
  | #ii# は それ以外の共有部分です。

FPU の Control Word を毎回 バックアップ→設定→リストアしているあたりや、 unsigned int を FPU に読ませるため mint64 に拡張するのに high 側に 毎回 $0 を書いているあたり、 FPU の register STACK 数に余裕があるはずなので そこらあたりも有効活用できそうなあたり、に まだ高速化の余地がありそう。

10.2R の package版 pnmscale のバイナリは 14.0R amd64 下でも /etc/libmap32.conf を設定すれば そのまま動くので、素直に持ち込んで そのまま使うのが真っ当ではありますが どうしようかなぁ…。
(高速化云々以前に amd64 で make した pnmscale が 4倍ぐらい早いことに気付いてしまった、悩みは尽きまじ…)

あっ -nomix側 確認してないや…、まぁこっちはいいか 基本使ってないし…。

- * - * -

P.S.1
patch しない元ソースを 14.0R amd64 にて評価して見ましたが -O0 でも -O2 でも同じ画像バイナリを出力しますね、 勝手に double 化はしていない感触。 (でも 10.2R の -O0 とは丸め誤差的な違いが…)
P.S.2
amd64 環境って、awk も 64bit になるのね…
% cat v
BEGIN {
    printf("%04x\n", -123);
    for(i=29; i<34; i++) {
        printf("%3d: %10d\n", i, 2^i);
    }
}
                                  ..............................
% awk -f v                        :  ちなみに i386(32bit) では
ffffffffffffff85                  :ffffff85
 29:  536870912                   : 29:  536870912
 30: 1073741824                   : 30: 1073741824
 31: 2147483648                   : 31: -2147483648
 32: 4294967296                   : 32: -2147483648
 33: 8589934592                   : 33: -2147483648


    uratan@miomio.jp
upward