fontisan 0.4.47 → 0.4.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/BENCHMARKS.md +56 -0
- data/CHANGELOG.md +12 -0
- data/Cargo.lock +550 -0
- data/Cargo.toml +12 -0
- data/Gemfile +10 -0
- data/README.adoc +52 -0
- data/Rakefile +5 -1
- data/benchmark/baseline.json +5 -0
- data/benchmark/ci_gate.rb +217 -0
- data/benchmark/corpus/harness.rb +175 -0
- data/benchmark/corpus/ops.rb +150 -0
- data/benchmark/corpus/run.rb +182 -0
- data/benchmark/corpus/scan_child.rb +8 -0
- data/benchmark/corpus/tiers.rb +176 -0
- data/benchmark/tier_baseline.json +12 -0
- data/docs/.vitepress/config.ts +2 -1
- data/docs/guide/engines.md +117 -0
- data/docs/reference-facade.md +130 -0
- data/ext/fontisan_cdylib/Cargo.lock +313 -0
- data/ext/fontisan_cdylib/Cargo.toml +18 -0
- data/ext/fontisan_cdylib/build.rs +22 -0
- data/ext/fontisan_cdylib/extconf.rb +36 -0
- data/ext/fontisan_cdylib/src/lib.rs +13 -0
- data/ext/fontisan_native/Cargo.toml +21 -0
- data/ext/fontisan_native/build.rs +22 -0
- data/ext/fontisan_native/clippy.toml +10 -0
- data/ext/fontisan_native/extconf.rb +59 -0
- data/ext/fontisan_native/src/lib.rs +463 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
- data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
- data/lib/fontisan/base_collection.rb +30 -0
- data/lib/fontisan/byte_ops.rb +219 -0
- data/lib/fontisan/cli.rb +5 -5
- data/lib/fontisan/commands/audit_command.rb +38 -43
- data/lib/fontisan/commands/convert_command.rb +53 -0
- data/lib/fontisan/commands/instance_command.rb +75 -0
- data/lib/fontisan/commands/subset_command.rb +43 -0
- data/lib/fontisan/commands/unpack_command.rb +56 -0
- data/lib/fontisan/converters/cff_table_builder.rb +1 -1
- data/lib/fontisan/converters/collection_converter.rb +13 -5
- data/lib/fontisan/converters/format_converter.rb +1 -1
- data/lib/fontisan/converters/woff2_encoder.rb +8 -6
- data/lib/fontisan/converters/woff_writer.rb +6 -5
- data/lib/fontisan/engine.rb +350 -0
- data/lib/fontisan/ffi/client.rb +121 -0
- data/lib/fontisan/ffi.rb +343 -0
- data/lib/fontisan/ffi_platform.rb +24 -0
- data/lib/fontisan/font_loader.rb +5 -1
- data/lib/fontisan/font_writer.rb +13 -1
- data/lib/fontisan/native.rb +231 -0
- data/lib/fontisan/reference/table_digests.rb +251 -0
- data/lib/fontisan/reference.rb +619 -0
- data/lib/fontisan/scan.rb +372 -0
- data/lib/fontisan/sfnt_directory.rb +41 -0
- data/lib/fontisan/sfnt_font.rb +16 -6
- data/lib/fontisan/subset/builder.rb +2 -1
- data/lib/fontisan/tables/cff/charstring.rb +16 -0
- data/lib/fontisan/tables/cff.rb +15 -5
- data/lib/fontisan/tables/cmap.rb +11 -1
- data/lib/fontisan/tables/gpos.rb +7 -0
- data/lib/fontisan/tables/gsub.rb +7 -0
- data/lib/fontisan/tables/layout_common.rb +172 -0
- data/lib/fontisan/tables/name.rb +10 -4
- data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
- data/lib/fontisan/validators/opentype_validator.rb +2 -0
- data/lib/fontisan/variable/instancer.rb +1 -0
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/collection_decoder.rb +15 -1
- data/lib/fontisan/woff2/collection_encoder.rb +29 -6
- data/lib/fontisan/woff2/encoder_rules.rb +12 -4
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
- data/lib/fontisan/woff2_collection.rb +7 -5
- data/lib/fontisan/woff2_font.rb +12 -3
- data/lib/fontisan/woff_container.rb +83 -0
- data/lib/fontisan/woff_font.rb +24 -20
- data/lib/fontisan.rb +24 -1
- data/rakelib/bench.rake +9 -0
- data/rakelib/cdylib.rake +39 -0
- data/rakelib/corpus.rake +272 -0
- data/rakelib/corpus_convert.rake +43 -0
- data/rakelib/corpus_goldens.rake +22 -0
- data/rakelib/corpus_malformed.rake +20 -0
- data/rakelib/corpus_scan.rake +24 -0
- data/rakelib/java.rake +61 -0
- data/rakelib/native.rake +43 -0
- data/scripts/build-woff2.sh +57 -0
- data/scripts/corpus/generate_convert_goldens.rb +478 -0
- data/scripts/corpus/generate_goldens.rb +282 -0
- data/scripts/corpus/generate_malformed_goldens.rb +254 -0
- data/scripts/corpus/generate_scan_goldens.rb +98 -0
- data/scripts/tier_fallback.rb +219 -0
- data/scripts/tier_smoke.rb +64 -0
- metadata +68 -3
data/README.adoc
CHANGED
|
@@ -53,6 +53,38 @@ command mappings and usage examples.
|
|
|
53
53
|
round-trip. See link:docs/TYPE1_FONTS.adoc[Type 1 Fonts Guide].
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
== Engine
|
|
57
|
+
|
|
58
|
+
Fontisan can serve its read, audit, scan, convert, extract, instance and subset
|
|
59
|
+
operations from three tiers: `native` (a precompiled Rust extension), `ffi` (the
|
|
60
|
+
bundled Rust shared library, loaded through the `ffi` gem) and `ruby` (the pure
|
|
61
|
+
Ruby implementation, always present).
|
|
62
|
+
|
|
63
|
+
The default is `auto`: the first available tier wins, in the order
|
|
64
|
+
`native`, `ffi`, `ruby`. An operation a tier cannot serve goes to the next one.
|
|
65
|
+
Installed from the source gem on a machine with no Rust toolchain and no
|
|
66
|
+
bundled library, `auto` serves everything from `ruby`, with no warning and
|
|
67
|
+
no error.
|
|
68
|
+
|
|
69
|
+
[cols="1,3"]
|
|
70
|
+
|===
|
|
71
|
+
|Variable |Effect
|
|
72
|
+
|
|
73
|
+
|`FONTISAN_ENGINE=auto\|native\|ffi\|ruby`
|
|
74
|
+
|Pick the tier. Unset or empty means `auto`. `ruby` never uses the Rust tiers.
|
|
75
|
+
A forced tier that is not available falls back to `ruby`.
|
|
76
|
+
|
|
77
|
+
|`FONTISAN_ENGINE_STRICT=1`
|
|
78
|
+
|No fallback: a forced tier that is not available raises
|
|
79
|
+
`Fontisan::Engine::TierUnavailable` at setup. `1`, `true`, `yes` and `on` enable it;
|
|
80
|
+
unset, empty, `0`, `false`, `no` and `off` leave it off; any other value raises
|
|
81
|
+
`Fontisan::Engine::ConfigError`.
|
|
82
|
+
|===
|
|
83
|
+
|
|
84
|
+
`Fontisan::Engine.served_by(:read)` reports which tier served the last call of
|
|
85
|
+
an operation. See link:docs/guide/engines.md[Engines] for tiers, platforms and
|
|
86
|
+
troubleshooting.
|
|
87
|
+
|
|
56
88
|
== Installation
|
|
57
89
|
|
|
58
90
|
Add this line to your application's Gemfile:
|
|
@@ -2734,6 +2766,26 @@ bundle exec rake fixtures:clean # Remove downloaded fonts
|
|
|
2734
2766
|
----
|
|
2735
2767
|
|
|
2736
2768
|
|
|
2769
|
+
=== Reference corpus and benchmarks
|
|
2770
|
+
|
|
2771
|
+
`corpus/` holds the golden outputs of the pure-Ruby engine (fontist/fontisan#157).
|
|
2772
|
+
The fonts themselves are not in git: `corpus/manifest.json` pins each source
|
|
2773
|
+
by URL and SHA-256, and `rake corpus:fetch` downloads and verifies them into
|
|
2774
|
+
`corpus/fonts/`. `corpus:verify` and `bench` run the fetch themselves, so a
|
|
2775
|
+
fresh clone needs no manual step.
|
|
2776
|
+
|
|
2777
|
+
[source,shell]
|
|
2778
|
+
----
|
|
2779
|
+
bundle exec rake corpus:fetch # download + checksum the ci tier (TIER=ci|large|all)
|
|
2780
|
+
bundle exec rake corpus:verify # compare read goldens; never writes
|
|
2781
|
+
bundle exec rake bench # run the benches, print the report, write BENCHMARKS.md
|
|
2782
|
+
----
|
|
2783
|
+
|
|
2784
|
+
Goldens are compared, never regenerated, by the tests. `corpus:regenerate` is
|
|
2785
|
+
the one explicit rewrite and belongs in a PR that shows the golden diff.
|
|
2786
|
+
Baseline numbers are in link:BENCHMARKS.md[BENCHMARKS.md]; the `Perf Gate`
|
|
2787
|
+
workflow fails a PR that makes an operation more than 25% slower than its base.
|
|
2788
|
+
|
|
2737
2789
|
== Copyright and license
|
|
2738
2790
|
|
|
2739
2791
|
Copyright https://www.ribose.com[Ribose].
|
data/Rakefile
CHANGED
|
@@ -146,7 +146,11 @@ namespace :fixtures do
|
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
# RSpec task depends on fixtures
|
|
149
|
-
|
|
149
|
+
# Corpus specs need `rake corpus:fetch` and run in the corpus workflow
|
|
150
|
+
# (.github/workflows/corpus.yml), not in the default suite.
|
|
151
|
+
RSpec::Core::RakeTask.new(spec: "fixtures:download") do |t|
|
|
152
|
+
t.exclude_pattern = "spec/corpus/**/*_spec.rb"
|
|
153
|
+
end
|
|
150
154
|
|
|
151
155
|
# Default task runs spec and rubocop
|
|
152
156
|
task default: %i[spec rubocop]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"reference_commit": "d6bc12ad648e7a3fcca58fb3d2d0139091135c87",
|
|
4
|
+
"note": "fontisan main at d6bc12a (Phase 0), the first commit with Fontisan::Reference, so every bench op can run on it. The weekly gate compares head with this commit in one job and fails on an op the commit cannot run (NO-BASE). Move it deliberately in a reviewed change."
|
|
5
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Performance gate for the reference corpus benches (fontist/fontisan#157).
|
|
5
|
+
#
|
|
6
|
+
# --mode pr base (--base REF) vs head (--head REF, default HEAD), same
|
|
7
|
+
# job, interleaved. This is the per-PR check.
|
|
8
|
+
# --mode weekly head vs the pinned reference commit in benchmark/baseline.json,
|
|
9
|
+
# same job (ratios, never absolute times). A missing or
|
|
10
|
+
# unreachable reference fails, and so does an op the
|
|
11
|
+
# reference cannot run (NO-BASE): move reference_commit
|
|
12
|
+
# to a commit that can run every op.
|
|
13
|
+
# --mode tiers ruby vs native vs ffi (FONTISAN_ENGINE, strict) on the working
|
|
14
|
+
# tree, same job, interleaved. Hard floor (#160): native must be
|
|
15
|
+
# at least as fast as ruby on audit_file, audit_dir and scan_dir
|
|
16
|
+
# and its scan peak RSS must not exceed ruby's (3% tolerance).
|
|
17
|
+
# Regression gate: native factors must stay within 25% of
|
|
18
|
+
# benchmark/tier_baseline.json for --platform (default
|
|
19
|
+
# Gem::Platform.local; no entry = floor only). With --write FILE the
|
|
20
|
+
# factor table is written into the "Engine tier factors"
|
|
21
|
+
# section of FILE (BENCHMARKS.md), tagged with --label.
|
|
22
|
+
# --mode report head only (working tree); prints the report and, with
|
|
23
|
+
# --write FILE, writes BENCHMARKS.md. Used by `rake bench`.
|
|
24
|
+
#
|
|
25
|
+
# Each side is a `git archive` copy with its own bundle. Ops the base cannot
|
|
26
|
+
# run (e.g. Fontisan::Reference before it exists) are reported "new", not
|
|
27
|
+
# compared. Exit 0 pass, 1 regression, 2 error.
|
|
28
|
+
#
|
|
29
|
+
# FONTISAN_BENCH_SLOWDOWN=2 slows only the head side 2x (gate self-test);
|
|
30
|
+
# with FONTISAN_BENCH_SLOWDOWN_TIER=native only the native tier is slowed.
|
|
31
|
+
|
|
32
|
+
require "json"
|
|
33
|
+
require "optparse"
|
|
34
|
+
require "etc"
|
|
35
|
+
require "time"
|
|
36
|
+
require_relative "corpus/harness"
|
|
37
|
+
require_relative "corpus/tiers"
|
|
38
|
+
|
|
39
|
+
module FontisanBench
|
|
40
|
+
# Command line front end.
|
|
41
|
+
class Gate
|
|
42
|
+
ROOT = File.expand_path("..", __dir__)
|
|
43
|
+
HARNESS = File.join(__dir__, "corpus")
|
|
44
|
+
BASELINE = File.join(__dir__, "baseline.json")
|
|
45
|
+
FACTORS_HEADING = "## Engine tier factors"
|
|
46
|
+
FACTORS_SECTION = /^#{FACTORS_HEADING}.*?(?=^## |\z)/m
|
|
47
|
+
FACTORS_TAIL = /^#{FACTORS_HEADING}.*/m
|
|
48
|
+
|
|
49
|
+
def initialize(argv)
|
|
50
|
+
@opts = { mode: "pr", head: "HEAD", rounds: 5, tolerance: 0.25,
|
|
51
|
+
corpus: File.join(ROOT, "corpus") }
|
|
52
|
+
OptionParser.new do |o|
|
|
53
|
+
o.on("--mode MODE") { |v| @opts[:mode] = v }
|
|
54
|
+
o.on("--base REF") { |v| @opts[:base] = v }
|
|
55
|
+
o.on("--head REF") { |v| @opts[:head] = v }
|
|
56
|
+
o.on("--rounds N", Integer) { |v| @opts[:rounds] = v }
|
|
57
|
+
o.on("--tolerance F", Float) { |v| @opts[:tolerance] = v }
|
|
58
|
+
o.on("--corpus DIR") { |v| @opts[:corpus] = v }
|
|
59
|
+
o.on("--baseline FILE") { |v| @opts[:baseline] = v }
|
|
60
|
+
o.on("--save FILE") { |v| @opts[:save] = v }
|
|
61
|
+
o.on("--write FILE") { |v| @opts[:write] = v }
|
|
62
|
+
o.on("--label TEXT") { |v| @opts[:label] = v }
|
|
63
|
+
o.on("--platform NAME") { |v| @opts[:platform] = v }
|
|
64
|
+
o.on("--tier-baseline FILE") { |v| @opts[:tier_baseline] = v }
|
|
65
|
+
end.parse!(argv)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call
|
|
69
|
+
return report if @opts[:mode] == "report"
|
|
70
|
+
return tiers if @opts[:mode] == "tiers"
|
|
71
|
+
|
|
72
|
+
Dir.mktmpdir("fontisan-gate") { |work| compare(work) }
|
|
73
|
+
rescue Error => e
|
|
74
|
+
warn "ERROR: #{e.message}"
|
|
75
|
+
2
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def compare(work)
|
|
81
|
+
base_ref = @opts[:mode] == "weekly" ? reference_commit : @opts[:base]
|
|
82
|
+
raise Error, "--base REF is required for --mode pr" unless base_ref
|
|
83
|
+
|
|
84
|
+
sides = [Side.from_ref("base", base_ref, root: ROOT, workdir: work),
|
|
85
|
+
Side.from_ref("head", @opts[:head], root: ROOT, workdir: work)]
|
|
86
|
+
m = Measurement.interleave(sides, rounds: @opts[:rounds], harness_dir: HARNESS, corpus: @opts[:corpus])
|
|
87
|
+
rows = Compare.rows(m, tolerance: @opts[:tolerance], strict: @opts[:mode] == "weekly")
|
|
88
|
+
rows = confirm(m, sides, rows)
|
|
89
|
+
title = @opts[:mode] == "weekly" ? "Perf gate: head vs pinned reference" : "Perf gate: head vs base"
|
|
90
|
+
puts Compare.markdown(rows, title: "#{title} (#{sides[0].ref[0, 8]} -> #{sides[1].ref[0, 8]})",
|
|
91
|
+
tolerance: @opts[:tolerance], rounds: @opts[:rounds])
|
|
92
|
+
save(m, rows, sides)
|
|
93
|
+
Compare.failed?(rows) ? 1 : 0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A shared runner is noisy: ops that look regressed are re-measured in
|
|
97
|
+
# the same job (more interleaved rounds, medians over all rounds) and only
|
|
98
|
+
# fail if they still regress.
|
|
99
|
+
def confirm(measure, sides, rows)
|
|
100
|
+
suspects = rows.select { |r| r.status == "REGRESSION" }.map(&:metric)
|
|
101
|
+
return rows if suspects.empty?
|
|
102
|
+
|
|
103
|
+
ops = suspects - ["scan_peak_rss_kb"]
|
|
104
|
+
ops = ["scan_dir"] if ops.empty?
|
|
105
|
+
warn "confirming #{suspects.join(', ')} with #{@opts[:rounds]} more rounds"
|
|
106
|
+
Measurement.interleave(sides, rounds: @opts[:rounds], harness_dir: HARNESS,
|
|
107
|
+
corpus: @opts[:corpus], only: ops, into: measure)
|
|
108
|
+
Compare.rows(measure, tolerance: @opts[:tolerance], strict: @opts[:mode] == "weekly")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def tiers
|
|
112
|
+
sides = Tiers.sides(ROOT)
|
|
113
|
+
args = { harness_dir: HARNESS, corpus: @opts[:corpus] }
|
|
114
|
+
m = Tiers.measure(sides, rounds: @opts[:rounds], **args)
|
|
115
|
+
baseline = tier_baseline
|
|
116
|
+
rows = Tiers.rows(m, baseline: baseline)
|
|
117
|
+
if Tiers.failed?(rows)
|
|
118
|
+
warn "confirming with #{@opts[:rounds]} more rounds"
|
|
119
|
+
Tiers.measure(sides, rounds: @opts[:rounds], into: m, **args)
|
|
120
|
+
rows = Tiers.rows(m, baseline: baseline)
|
|
121
|
+
end
|
|
122
|
+
text = Tiers.markdown(rows, title: "Perf gate: engine tiers (#{@opts[:label] || runner_description})",
|
|
123
|
+
rounds: @opts[:rounds])
|
|
124
|
+
puts text
|
|
125
|
+
write_factors(text)
|
|
126
|
+
save(m, [], sides)
|
|
127
|
+
Tiers.failed?(rows) ? 1 : 0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The committed expected factors for --platform (default: this Ruby's).
|
|
131
|
+
def tier_baseline
|
|
132
|
+
Tiers.baseline(@opts[:platform] || Gem::Platform.local.to_s,
|
|
133
|
+
file: @opts[:tier_baseline] || Tiers::BASELINE_FILE)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def write_factors(text)
|
|
137
|
+
return unless @opts[:write]
|
|
138
|
+
|
|
139
|
+
file = @opts[:write]
|
|
140
|
+
body = File.exist?(file) ? File.read(file) : ""
|
|
141
|
+
section = "#{FACTORS_HEADING}\n\n#{factors_section(text)}\n"
|
|
142
|
+
body = body.sub(FACTORS_SECTION, "").rstrip
|
|
143
|
+
File.write(file, "#{body}\n\n#{section}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def factors_section(text)
|
|
147
|
+
lines = text.lines.map(&:chomp).drop(1) # drop the "## title" line
|
|
148
|
+
["- Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM}); #{@opts[:label] || runner_description}",
|
|
149
|
+
"- fontisan SHA: `#{Side.capture('git', 'rev-parse', 'HEAD', chdir: ROOT).strip}`", *lines].join("\n").rstrip
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def reference_commit
|
|
153
|
+
file = @opts[:baseline] || BASELINE
|
|
154
|
+
raise Error, "reference baseline #{file} is missing" unless File.exist?(file)
|
|
155
|
+
|
|
156
|
+
ref = JSON.parse(File.read(file))["reference_commit"].to_s
|
|
157
|
+
raise Error, "#{file} has no reference_commit" if ref.empty?
|
|
158
|
+
|
|
159
|
+
ref
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def report
|
|
163
|
+
head = Side.new("head", ROOT, ref: Side.capture("git", "rev-parse", "HEAD", chdir: ROOT).strip)
|
|
164
|
+
m = Measurement.interleave([head], rounds: @opts[:rounds], harness_dir: HARNESS, corpus: @opts[:corpus])
|
|
165
|
+
text = report_markdown(m, head)
|
|
166
|
+
puts text
|
|
167
|
+
File.write(@opts[:write], text + kept_factors) if @opts[:write]
|
|
168
|
+
save(m, [], [head])
|
|
169
|
+
0
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# `rake bench` regenerates the file; the tier factor table (written by
|
|
173
|
+
# --mode tiers) is carried over.
|
|
174
|
+
def kept_factors
|
|
175
|
+
return "" unless File.exist?(@opts[:write])
|
|
176
|
+
|
|
177
|
+
kept = File.read(@opts[:write])[FACTORS_TAIL]
|
|
178
|
+
kept ? "\n#{kept.rstrip}\n" : ""
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def report_markdown(measure, head)
|
|
182
|
+
dirty = Side.capture("git", "status", "--porcelain", "--", "lib", chdir: ROOT).strip.empty? ? "" : "+dirty"
|
|
183
|
+
lines = ["# Benchmarks", "",
|
|
184
|
+
"Reference corpus `ci` tier benches for the #157 operations (`rake bench`).", "",
|
|
185
|
+
"- fontisan SHA: `#{head.ref}#{dirty}`",
|
|
186
|
+
"- Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})",
|
|
187
|
+
"- Runner: #{runner_description}",
|
|
188
|
+
"- Rounds: #{@opts[:rounds]} (median per op); each op is the fastest of repeated calls " \
|
|
189
|
+
"(at least 3 calls or 0.25 s of measured time)", "",
|
|
190
|
+
"| operation | median |", "|---|---|"]
|
|
191
|
+
measure.op_names.each { |op| lines << "| #{op} | #{Compare.fmt(op, measure.median_op('head', op))} |" }
|
|
192
|
+
lines << "| scan_peak_rss (directory scan) | #{Compare.fmt('scan_peak_rss_kb', measure.median_rss('head'))} |"
|
|
193
|
+
"#{lines.join("\n")}\n"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def runner_description
|
|
197
|
+
cpu = if RUBY_PLATFORM.include?("darwin")
|
|
198
|
+
Side.capture("sysctl", "-n", "machdep.cpu.brand_string").strip
|
|
199
|
+
else
|
|
200
|
+
File.foreach("/proc/cpuinfo").grep(/model name/).first.to_s.split(":", 2).last.to_s.strip
|
|
201
|
+
end
|
|
202
|
+
"#{ENV.fetch('RUNNER_OS', RbConfig::CONFIG['host_os'])}, #{cpu}, #{Etc.nprocessors} cores"
|
|
203
|
+
rescue StandardError
|
|
204
|
+
RbConfig::CONFIG["host_os"]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def save(measure, rows, sides)
|
|
208
|
+
return unless @opts[:save]
|
|
209
|
+
|
|
210
|
+
data = { "sides" => sides.to_h { |s| [s.name, s.ref] }, "runs" => measure.runs,
|
|
211
|
+
"rows" => rows.map(&:to_h) }
|
|
212
|
+
File.write(@opts[:save], JSON.pretty_generate(data))
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
exit FontisanBench::Gate.new(ARGV).call if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
module FontisanBench
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# One copy of fontisan to measure, with its own bundle.
|
|
13
|
+
class Side
|
|
14
|
+
attr_reader :name, :dir, :ref
|
|
15
|
+
|
|
16
|
+
# @param name [String] "base" or "head"
|
|
17
|
+
# @param dir [String] checkout directory (own Gemfile)
|
|
18
|
+
def initialize(name, dir, ref: nil)
|
|
19
|
+
@name = name
|
|
20
|
+
@dir = dir
|
|
21
|
+
@ref = ref
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# `git archive REF` into a fresh directory with its own bundle. A git
|
|
25
|
+
# archive has no .git, so the gemspec lists no files but still loads, and
|
|
26
|
+
# nothing here relies on exe/.
|
|
27
|
+
def self.from_ref(name, ref, root:, workdir:)
|
|
28
|
+
sha = capture("git", "rev-parse", "--verify", "#{ref}^{commit}", chdir: root).strip
|
|
29
|
+
dir = File.join(workdir, name)
|
|
30
|
+
FileUtils.mkdir_p(dir)
|
|
31
|
+
archive = File.join(workdir, "#{name}.tar")
|
|
32
|
+
run!("git", "archive", "--format=tar", "-o", archive, sha, chdir: root)
|
|
33
|
+
run!("tar", "-xf", archive, "-C", dir)
|
|
34
|
+
lock = File.join(root, "Gemfile.lock")
|
|
35
|
+
FileUtils.cp(lock, dir) if File.exist?(lock)
|
|
36
|
+
side = new(name, dir, ref: sha)
|
|
37
|
+
side.bundle_install
|
|
38
|
+
side
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.capture(*cmd, chdir: nil)
|
|
42
|
+
out, err, st = Open3.capture3(*cmd, chdir: chdir || Dir.pwd)
|
|
43
|
+
raise Error, "#{cmd.join(' ')} failed: #{err}" unless st.success?
|
|
44
|
+
|
|
45
|
+
out
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.run!(*cmd, chdir: nil, env: {})
|
|
49
|
+
out, st = Open3.capture2e(env, *cmd, chdir: chdir || Dir.pwd)
|
|
50
|
+
raise Error, "#{cmd.join(' ')} failed:\n#{out}" unless st.success?
|
|
51
|
+
|
|
52
|
+
out
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def env
|
|
56
|
+
{ "BUNDLE_GEMFILE" => File.join(dir, "Gemfile"), "BUNDLE_FROZEN" => "false",
|
|
57
|
+
"BUNDLE_DEPLOYMENT" => "false" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def bundle_install
|
|
61
|
+
self.class.run!("bundle", "install", "--quiet", env: env, chdir: dir)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One process: every op once-per-calibration, JSON result.
|
|
65
|
+
def run_once(harness_dir, corpus, only: nil)
|
|
66
|
+
cmd = ["bundle", "exec", RbConfig.ruby, File.join(harness_dir, "run.rb"),
|
|
67
|
+
"--corpus", corpus, "--side", name]
|
|
68
|
+
cmd += ["--only", only.join(",")] if only
|
|
69
|
+
out, err, st = Open3.capture3(env, *cmd, chdir: dir)
|
|
70
|
+
raise Error, "bench run on #{name} failed:\n#{err}" unless st.success?
|
|
71
|
+
|
|
72
|
+
JSON.parse(out.lines.reverse_each.find { |l| l.start_with?("{") })
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Interleaved rounds over two (or one) sides, median per metric.
|
|
77
|
+
class Measurement
|
|
78
|
+
METRICS_RSS = "scan_peak_rss_kb"
|
|
79
|
+
|
|
80
|
+
attr_reader :runs
|
|
81
|
+
|
|
82
|
+
def initialize
|
|
83
|
+
@runs = Hash.new { |h, k| h[k] = [] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def record(side_name, result)
|
|
87
|
+
@runs[side_name] << result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def op_names
|
|
91
|
+
@runs.values.flatten.flat_map { |r| r["ops"].keys }.uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def median_op(side, name)
|
|
95
|
+
median(@runs[side].map { |r| r["ops"][name] })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def median_rss(side)
|
|
99
|
+
median(@runs[side].map { |r| r[METRICS_RSS] })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def median(values)
|
|
103
|
+
v = values.compact.sort
|
|
104
|
+
return nil if v.empty?
|
|
105
|
+
|
|
106
|
+
mid = v.size / 2
|
|
107
|
+
v.size.odd? ? v[mid] : (v[mid - 1] + v[mid]) / 2.0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Round i runs the sides in alternating order, so drift (thermal, noisy
|
|
111
|
+
# neighbours) hits both sides alike.
|
|
112
|
+
def self.interleave(sides, rounds:, harness_dir:, corpus:, log: $stderr, only: nil, into: nil)
|
|
113
|
+
m = into || new
|
|
114
|
+
rounds.times do |i|
|
|
115
|
+
order = i.even? ? sides : sides.reverse
|
|
116
|
+
order.each do |side|
|
|
117
|
+
log.puts "round #{i + 1}/#{rounds}: #{side.name}"
|
|
118
|
+
m.record(side.name, side.run_once(harness_dir, corpus, only: only))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
m
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# base vs head verdicts.
|
|
126
|
+
module Compare
|
|
127
|
+
Row = Struct.new(:metric, :base, :head, :ratio, :status, keyword_init: true)
|
|
128
|
+
|
|
129
|
+
module_function
|
|
130
|
+
|
|
131
|
+
# `strict` (weekly mode): the pinned reference must be able to run every
|
|
132
|
+
# op, so an op with no base number is "NO-BASE" and fails instead of "new".
|
|
133
|
+
def rows(measure, tolerance:, strict: false)
|
|
134
|
+
ops = measure.op_names.map do |op|
|
|
135
|
+
row(op, measure.median_op("base", op), measure.median_op("head", op), tolerance, strict: strict)
|
|
136
|
+
end
|
|
137
|
+
ops << row("scan_peak_rss_kb", measure.median_rss("base"), measure.median_rss("head"), tolerance, strict: strict)
|
|
138
|
+
ops
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def row(metric, base, head, tolerance, strict: false)
|
|
142
|
+
return Row.new(metric: metric, base: base, head: head, status: "n/a") if head.nil? && base.nil?
|
|
143
|
+
return Row.new(metric: metric, base: base, head: head, status: "MISSING-ON-HEAD") if head.nil?
|
|
144
|
+
return Row.new(metric: metric, base: base, head: head, status: strict ? "NO-BASE" : "new") if base.nil?
|
|
145
|
+
|
|
146
|
+
ratio = head.to_f / base
|
|
147
|
+
Row.new(metric: metric, base: base, head: head, ratio: ratio,
|
|
148
|
+
status: ratio > 1 + tolerance ? "REGRESSION" : "ok")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def failed?(rows)
|
|
152
|
+
rows.any? { |r| %w[REGRESSION MISSING-ON-HEAD NO-BASE].include?(r.status) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def markdown(rows, title:, tolerance:, rounds:)
|
|
156
|
+
out = ["## #{title}", "",
|
|
157
|
+
"Median of #{rounds} interleaved rounds; fails when head is more than " \
|
|
158
|
+
"#{(tolerance * 100).round}% slower than base.", "",
|
|
159
|
+
"| metric | base | head | head/base | status |", "|---|---|---|---|---|"]
|
|
160
|
+
rows.each do |r|
|
|
161
|
+
out << "| #{r.metric} | #{fmt(r.metric, r.base)} | #{fmt(r.metric, r.head)} | " \
|
|
162
|
+
"#{r.ratio ? format('%.2fx', r.ratio) : '-'} | #{r.status} |"
|
|
163
|
+
end
|
|
164
|
+
out << ""
|
|
165
|
+
out << (failed?(rows) ? "**RESULT: FAIL**" : "**RESULT: PASS**")
|
|
166
|
+
out.join("\n")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def fmt(metric, value)
|
|
170
|
+
return "-" if value.nil?
|
|
171
|
+
|
|
172
|
+
metric.end_with?("_kb") ? "#{(value / 1024.0).round(1)} MB" : format("%.1f ms", value * 1000)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module FontisanBench
|
|
7
|
+
# One benchmarked operation. `requires` lists the constants the measured
|
|
8
|
+
# fontisan must define; an operation the measured tree cannot run is
|
|
9
|
+
# skipped, which the comparison reports as "new" (it has no base number).
|
|
10
|
+
Op = Struct.new(:name, :description, :requires, :body, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# The #157 operations, run against the `ci` tier of the reference corpus
|
|
13
|
+
# (corpus/fonts, fetched by `rake corpus:fetch`).
|
|
14
|
+
#
|
|
15
|
+
# Table parse, names, cmap, instancing and conversion call the classes the
|
|
16
|
+
# reference facade wraps, so the same code runs on a base that predates
|
|
17
|
+
# `Fontisan::Reference`; audit, audit_dir and scan go through the facade.
|
|
18
|
+
class Ops
|
|
19
|
+
SS_TTF = "source-sans-3-regular-ttf/SourceSans3-Regular.ttf"
|
|
20
|
+
SS_OTF = "source-sans-3-regular-otf/SourceSans3-Regular.otf"
|
|
21
|
+
SS_WOFF = "source-sans-3-regular-ttf-woff/SourceSans3-Regular.ttf.woff"
|
|
22
|
+
SS_WOFF2 = "source-sans-3-regular-ttf-woff2/SourceSans3-Regular.ttf.woff2"
|
|
23
|
+
SS_VF = "source-sans-3-vf-upright-ttf/SourceSans3VF-Upright.ttf"
|
|
24
|
+
NOTO = "noto-sans-hinted-regular/NotoSans-Regular.ttf"
|
|
25
|
+
TABLE_PARSE_FONTS = [SS_TTF, SS_OTF, NOTO].freeze
|
|
26
|
+
NAME_FONTS = [SS_TTF, SS_OTF, SS_VF, NOTO].freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :corpus
|
|
29
|
+
|
|
30
|
+
def initialize(corpus)
|
|
31
|
+
@corpus = File.expand_path(corpus)
|
|
32
|
+
@fonts_dir = File.join(@corpus, "fonts")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def all
|
|
36
|
+
[table_parse, hmtx_metrics, names, cmap_coverage, audit_file, audit_dir, scan,
|
|
37
|
+
vf_instance, woff2_decode, convert_roundtrip, woff2_encode]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scan_paths
|
|
41
|
+
Dir.glob(File.join(@fonts_dir, "**", "*")).select { |f| File.file?(f) }.sort
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def font(rel)
|
|
47
|
+
File.join(@fonts_dir, rel)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def load_full(rel)
|
|
51
|
+
Fontisan::FontLoader.load(font(rel), mode: :full, lazy: false)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def op(name, description, requires: [], &body)
|
|
55
|
+
Op.new(name: name, description: description, requires: requires, body: body)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def table_parse
|
|
59
|
+
op("table_parse", "load (full) and parse every table of 3 static fonts") do
|
|
60
|
+
TABLE_PARSE_FONTS.each do |rel|
|
|
61
|
+
f = load_full(rel)
|
|
62
|
+
f.table_names.each { |tag| f.table(tag) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Tables::Hmtx#metric_for is the per-glyph metric lookup; table_parse only
|
|
68
|
+
# parses the table, so a slowdown inside the lookup was invisible.
|
|
69
|
+
def hmtx_metrics
|
|
70
|
+
op("hmtx_metrics", "hmtx metric_for every glyph of 3 static fonts") do
|
|
71
|
+
TABLE_PARSE_FONTS.each do |rel|
|
|
72
|
+
f = load_full(rel)
|
|
73
|
+
hmtx = f.table("hmtx")
|
|
74
|
+
hmtx.parse_with_context(f.table("hhea").number_of_h_metrics, f.table("maxp").num_glyphs) unless hmtx.parsed?
|
|
75
|
+
f.table("maxp").num_glyphs.times { |gid| hmtx.metric_for(gid) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def names
|
|
81
|
+
op("names", "name table: full, family, subfamily, postscript, 4 fonts") do
|
|
82
|
+
ids = [Fontisan::Tables::Name::FULL_NAME, Fontisan::Tables::Name::FAMILY,
|
|
83
|
+
Fontisan::Tables::Name::SUBFAMILY, Fontisan::Tables::Name::POSTSCRIPT_NAME]
|
|
84
|
+
NAME_FONTS.each do |rel|
|
|
85
|
+
f = Fontisan::FontLoader.load(font(rel), mode: :metadata)
|
|
86
|
+
name = f.table("name")
|
|
87
|
+
ids.each { |id| name.english_name(id) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cmap_coverage
|
|
93
|
+
op("cmap_coverage", "cmap unicode mappings of 3 static fonts") do
|
|
94
|
+
TABLE_PARSE_FONTS.each do |rel|
|
|
95
|
+
load_full(rel).table("cmap").unicode_mappings.size
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def audit_file
|
|
101
|
+
op("audit_file", "audit with validation, one font (facade)", requires: ["Fontisan::Reference"]) do
|
|
102
|
+
Fontisan::Reference.audit(font(SS_TTF), validate: true)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def audit_dir
|
|
107
|
+
op("audit_dir", "audit every file of the ci corpus directory (facade)",
|
|
108
|
+
requires: ["Fontisan::Reference"]) do
|
|
109
|
+
Fontisan::Reference.audit_dir(@fonts_dir, validate: false)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def scan
|
|
114
|
+
op("scan_dir", "scan rows for every file of the ci corpus directory (facade)",
|
|
115
|
+
requires: ["Fontisan::Reference"]) do
|
|
116
|
+
Fontisan::Reference.scan(scan_paths)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def vf_instance
|
|
121
|
+
op("vf_instance", "variable font instance at wght=700") do
|
|
122
|
+
Fontisan::Variable::Instancer.new(load_full(SS_VF)).instance({ "wght" => 700.0 })
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def woff2_decode
|
|
127
|
+
op("woff2_decode", "load a WOFF2 and parse every table") do
|
|
128
|
+
f = load_full(SS_WOFF2)
|
|
129
|
+
f.table_names.each { |tag| f.table(tag) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def convert_roundtrip
|
|
134
|
+
op("convert_roundtrip", "ttf -> woff -> ttf") do
|
|
135
|
+
Dir.mktmpdir("fontisan-bench") do |dir|
|
|
136
|
+
Fontisan.convert(font(SS_TTF), to: :woff, output: File.join(dir, "a.woff"), quiet: true)
|
|
137
|
+
Fontisan.convert(File.join(dir, "a.woff"), to: :ttf, output: File.join(dir, "b.ttf"), quiet: true)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def woff2_encode
|
|
143
|
+
op("woff2_encode", "ttf -> woff2") do
|
|
144
|
+
Dir.mktmpdir("fontisan-bench") do |dir|
|
|
145
|
+
Fontisan.convert(font(SS_TTF), to: :woff2, output: File.join(dir, "a.woff2"), quiet: true)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|