Fastest JavaScript HTML Escape

March 9, 2022

What is the fastest JavaScript HTML escape implementation? To find out, I:

  1. Wrote 10 different JavaScript HTML escape implementations.
  2. Created a web-based benchmarking tool which uses web workers and the Performance API to test with a variety of string sizes and generates a downloadable CSV of results.
  3. Created a set of scripts to aggregate and plot the results as SVGs.

Results

The times are from 64-bit Chrome 99 running in Debian on a Lenovo Thinkpad X1 Carbon (9th Gen); the specific timing results may vary for your system, but the relative results should be comparable.

The first chart shows implementations' mean call time (95% CI) as the string length varies:

String Size vs. HTML Escape Function Call Time (μs)

String Size vs. HTML Escape Function Call Time (μs)

The second chart comparse implementations' mean call time (95% CI) for 3000 character strings:

HTML Escape Function Call Times

HTML Escape Function Call Times

The red, blue, and green bars in this chart indicate the slow, medium, and fast functions, respectively.

Slow Functions

Anything that uses a capturing regular expression.

Example: h2

const h2 = (() => {
  // characters to match
  const M = /([&<>'"])/g;

  // map of char to entity
  const E = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    "'": '&apos;',
    '"': '&quot;',
  };

  // build and return escape function
  return (v) => v.replace(M, (_, c) => E[c]);
})();

 

The capture is definitely at fault, because the call times for identical non-capturing implementations (example: h4) are comparable to everything else.

Medium Functions

Except for the capturing regular expression implementations in the previous section, the remaining implementations' call times were comparable with one another. This includes:

  • Reducing an array of string literals and calling replace().
  • Several variants of reducing an array of non-capturing regular expression with replace().

Example: h4

const h4 = (() => {
  // characters to match
  const M = /[&<>'"]/g;

  // map of char to entity
  const E = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    "'": '&apos;',
    '"': '&quot;',
  };

  // build and return escape function
  return (v) => v.replace(M, (c) => E[c]);
})();

Fast Functions

Three implementations are slightly faster than the others. They all use replaceAll() and match on string literals. Their call times are indistinguishable from one another:

  • h7: Reduce, Replace All
  • h8: Reduce, Replace All, Frozen
  • h9: Replace All Literal

Example: h7

const h7 = (() => {
  const E = [
    ['&', '&amp;'],
    ['<', '&lt;'],
    ['>', '&gt;'],
    ["'", '&apos;'],
    ['"', '&quot;'],
  ];

  return (v) => E.reduce((r, e) => r.replaceAll(e[0], e[1]), v);
})();

 

The Winner: h9

Even though the call times for h7, h8, and h9 are indistinguishable, I actually prefer h9 because:

  • The most legible. It is the easiest implementation to read for beginning developers and developers who are uncomfortable with functional programming.
  • The simplist parse (probably).
  • Slightly easier for browsers to optimize (probably).

Here it is:

// html escape (replaceall explicit)
const h9 = (v) => {
  return v.replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll("'", '&apos;')
    .replaceAll('"', '&quot;');
};

 

Notes

  • The benchmarking interface, aggregation and plotting scripts, and additional information are available in the companion GitHub repository.
  • I also wrote a DOM/textContent implementation, but I couldn’t compare it with the other implementations because web workers don’t have DOM access. I would be surprised if it was as fast as the fast functions above.
  • Object.freeze() doesn’t appear to help, at least not in Chrome.

Update (2022-03-11): I posted the benchmarking tool online at the following URL: https://pmdn.org/fastest-js-html-escape/.

Tiny Binaries Redux

March 2, 2022

I regenerated the Tiny Binaries chart with the following changes:

Results

All Static Binary Sizes

All Static Binary Sizes

Notes

See the tiny-binaries GitHub repository for additional details and a table of results.

Relaxed Content-Security-Policy for Go Code Coverage Reports

February 25, 2022

There is a conflict between my strict Content-Security-Policy and the CSS and JavaScript embedded in the HTML code coverage reports generated by go cover.

I tested a couple of methods of overriding the base Content-Security-Policy, without success:

  1. Add a relaxed <meta http-equiv='Content-Security-Policy' content='...'> element.
  2. Embed the script and style as data: URLs.

(Aside: I’m glad browsers don’t allow these workarounds, because they would be potential security holes).

In any case, the my solution was to relax the policy for a specific location via the Apache config:

#
# Relax style-src and script-src content security policies for content
# in the "/coverage-reports" directory so that the HTML coverage reports
# generated by `go cover` work as expected.
#
# Specifically the relaxed constraints allow:
#
# 1. The inline `<script>` element at the end of the generated HTML.
# 2. `style='display: none'` attributes on hidden elements.
#
# Notes:
#
# * You *have* to use `Header set` rather than `Header append` to
#   replace rather than append to the existing header.
# * Ideally we'd use `style-src-elem` and `script-src-elem`, but neither
#   are currently supported by Firefox.
#
<Location /coverage-reports>
  Header set "Content-Security-Policy" "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"
</Location>

 

The situation could be improved by making the following changes to the go cover HTML report source code:

  1. Replace style="display: none" attribute on elements with class=hide.
  2. Add .hide { display: none } to the inline <style> element.
  3. Hash the inner content of the inline <style> element.
  4. Hash the inner content of the inline <script> element.
  5. Add a <meta http-equiv ...> element to the header, using the hashes from the previous two steps.

Example:

<meta
  http-equiv="content-security-policy"
  content="default-src 'self'; script-src 'self' 'sha256-a43KCehRqYcFBGPJxgYD6a15e6CRFwVvwuDAe8rGbkM='; style-src 'self' 'sha256-8OTC92xYkW7CWPJGhRvqCR0U1CR6L8PhhpRGGxgW4Ts='"
/>

 

Bonus: These reports would automatically set a reasonable policy even in the absense of a more restrictive server policy.

Counter-argument to this post: These coverage reports are meant to be simple and only used locally. More elaborate or more secure coverage reports should be generated by a separate tool rather than complicating the default one.

Media Shrinkage

January 28, 2022

Recently I made the following site improvements:

  1. Minified SVGs with minify.
  2. Created lossless WebPs of PNGs in recent content.
  3. Created a Hugo shortcode for the <picture> element wrapped in a <figure>.
  4. Updated bitmap images in recent content to default to WebP with a fallback to PNG (progressive enhancement).
  5. Configured mod_deflate to compress SVGs (see note about BREACH below).
  6. Enabled HTTP/2 (how did miss I this before?).
  7. Added a Cache-Control header for static style, script, and image assets.

Note: Using HTTP compression (mod_deflate, mod_brotli, etc) with dynamic web pages can expose you to a BREACH attack. This site is statically generated (via Hugo) so BREACH is not an issue.

Results

68% cumulative reduction in the total size of an uncached front page load in Chrome:

DescriptionTotal Size (kB)Reduction (%)
Baseline uncached front page load596 kB0%
WebP and minified SVGs324 kB45%
WebP, minified SVGs, and mod_deflate update185 kB68%

Notes

Update (2022-01-29): Small formatting, grammar, and spelling fixes. Added HTTP/2 note, Cache-Control note, BREACH warning, and tdewolff/minify link.

Postgres Trigger Tests

January 23, 2022

We already know statement-level INSERT triggers are faster than row-level triggers in Postgres.

Out of curiosity I decided to time row-level and statement-level INSERT triggers with a variety of row counts.

Here are the results:

Row Count vs Query Time

Row Count vs Query Time

The takeaway here is that if speed is a concern, then you should prefer statement-level triggers to row-level triggers.

The scripts used to create the test database, run the tests, and generate the results are available in the companion GitHub repository.

Generics in Go 1.18

January 17, 2022

I spent some time trying the generics types and generic functions in Go 1.18, and I like what they’ve got so far.

Below is a Go 1.17 program which prints the results of the following functions:

  • SumInts(): calculate the the sum of of a slice of int values.
  • SumFloats(): calculate the the sum of of a slice of float32 values.
  • SumComplexes(): calculate the the sum of of a slice of complex64 values.
package main

import "fmt"

// Return sum of all values in slice of ints.
func SumInts(m []int) int {
  var r int

  for _, v := range(m) {
    r += v
  }

  return r
}

// Return sum of all values in slice of float32s.
func SumFloats(m []float32) float32 {
  var r float32

  for _, v := range(m) {
    r += v
  }

  return r
}

// Return sum of all values in slice of complex64s.
func SumComplexes(m []complex64) complex64 {
  var r complex64

  for _, v := range(m) {
    r += v
  }

  return r
}

var (
  // test integers
  ints = []int { 10, 20, 30 }

  // test floating point numbers
  floats = []float32 { 10.0, 20.0, 30.0 }

  // test complex numbers
  complexes = []complex64 { complex(10, 1), complex(20, 2), complex(30, 3) }
)

func main() {
  // print sums
  fmt.Printf("ints = %d\n", SumInts(ints))
  fmt.Printf("floats = %2.1f\n", SumFloats(floats))
  fmt.Printf("complexes = %g\n", SumComplexes(complexes))
}

 

Here’s the same program, written using Go 1.18 generics:

package main

import "fmt"

// Return sum of all numeric values in slice.
func Sum[V ~int|~float32|~complex64](vals []V) V {
  var r V

  for _, v := range(vals) {
    r += v
  }

  return r
}

var (
  // test integers
  ints = []int { 10, 20, 30 }

  // test floating point numbers
  floats = []float32 { 10.0, 20.0, 30.0 }

  // test complex numbers
  complexes = []complex64 { complex(10, 1), complex(20, 2), complex(30, 3) }
)

func main() {
  // print sums using generics w/explicit types
  fmt.Printf("ints = %d\n", Sum[int](ints))
  fmt.Printf("floats = %2.1f\n", Sum[float32](floats))
  fmt.Printf("complexes = %g\n", Sum[complex64](complexes))
}

 

You can use type inference to drop the type parameters in many instances. For example, we can rewrite main() from the previous example like this:

func main() {
  // print sums using generics w/explicit types
  fmt.Printf("ints = %d\n", Sum(ints))
  fmt.Printf("floats = %2.1f\n", Sum(floats))
  fmt.Printf("complexes = %g\n", Sum(complexes))
}

 

Generics can also be used in type definitions. Example:

package main

import "fmt"

// Fraction
type Frac[T ~int|~int32|~int64] struct {
  num T // numerator
  den T // denominator
}

// Add two fractions.
func (a Frac[T]) Add(b Frac[T]) Frac[T] {
  return Frac[T] { a.num + b.num, a.den * b.den }
}

// Multiple fractions.
func (a Frac[T]) Mul(b Frac[T]) Frac[T] {
  return Frac[T] { a.num * b.num, a.den * b.den }
}

// Return inverse of fraction.
func (a Frac[T]) Inverse() Frac[T] {
  return Frac[T] { a.den, a.num }
}

// Return string representation of fraction.
func (a Frac[T]) String() string {
  return fmt.Sprintf("%d/%d", a.num, a.den)
}

func main() {
  // test fractions
  fracs = []Frac[int] {
    Frac[int] { 1, 2 },
    Frac[int] { 3, 4 },
    Frac[int] { 5, 6 },
  }

  // print fractions
  for _, f := range(fracs) {
    fmt.Printf("%s => %s\n", f, f.Mul(f.Add(f.Inverse())))
  }
}

 

Interface type declarations can now be used to define the constraints that a matching type must satisfy. In addition to the ability to specify methods that a matching type must implement, a type constraint specified as an interface may also specify a union of terms indicating the set of matching types.

Type union terms can be tilde-prefixed (example: ~int), which indicates that the underlying type must match the given type.

For example, the Frac type declaration from the previous example could be written like this instead:

// Integral number type.
type integral interface {
  ~int | ~int32 | ~int64
}

// Fraction
type Frac[T integral] struct {
  num T // numerator
  den T // denominator
}

 

There are two new predeclared identifiers:

  • any: An alias for interface {}.
  • comparable: Any type which can be compared for equality with == and !=. Useful for the parameterizing map keys.

There is a new constraints package, which (not yet visible in the online Go documentation as of this writing) that provides a couple of useful unions, but it’s relatively anemic at the moment:

$ go1.18beta1 doc -all constraints
package constraints // import "constraints"

Package constraints defines a set of useful constraints to be used with type
parameters.

TYPES

type Complex interface {
	~complex64 | ~complex128
}
    Complex is a constraint that permits any complex numeric type. If future
    releases of Go add new predeclared complex numeric types, this constraint
    will be modified to include them.

type Float interface {
	~float32 | ~float64
}
    Float is a constraint that permits any floating-point type. If future
    releases of Go add new predeclared floating-point types, this constraint
    will be modified to include them.

type Integer interface {
	Signed | Unsigned
}
    Integer is a constraint that permits any integer type. If future releases of
    Go add new predeclared integer types, this constraint will be modified to
    include them.

type Ordered interface {
	Integer | Float | ~string
}
    Ordered is a constraint that permits any ordered type: any type that
    supports the operators < <= >= >. If future releases of Go add new ordered
    types, this constraint will be modified to include them.

type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}
    Signed is a constraint that permits any signed integer type. If future
    releases of Go add new predeclared signed integer types, this constraint
    will be modified to include them.

type Unsigned interface {
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
    Unsigned is a constraint that permits any unsigned integer type. If future
    releases of Go add new predeclared unsigned integer types, this constraint
    will be modified to include them.

 

Using the constraints package, the Frac type from the previous example could be written like this:

import "constraints"

// Fraction
type Frac[T constraints.Signed] struct {
  num T // numerator
  den T // denominator
}

 

And with constraints, the Sum() function from the first example could be defined like this:

// Numeric value.
type Number interface {
  constraints.Integer | constraints.Float | constraints.Complex
}

// Return sum of all numeric values in slice.
func Sum[V Number](vals []V) V {
  var r V

  for _, v := range(vals) {
    r += v
  }

  return r
}

 

Other useful tidbits:

Update (2021-01-19): Minor wording changes, add information about tilde prefixes in type constraints.

Update (2021-02-13): The constraints package was moved to x/exp for Go 1.18. LWN has an excellent summary of Go 1.18.

Update (2021-03-17): Go 1.18 released.

Tiny Binaries: Assembly Optimization

January 1, 2022

Here’s how I reduced the assembly binary size in Tiny Binaries from 456 bytes to 114 bytes.

Shrinking the Code

Below is the original assembly code (asm-naive in the results):

;
; hi.s: unoptimized linux x86-64 assembly implementation.
;

bits 64

global _start

section .rodata
  ; "hi!\n"
  hi  db  "hi!", 10
  len equ $ - hi

section .text

_start:
  mov rax, 1 ; write
  mov rdi, 1 ; fd
  mov rsi, hi ; msg
  mov rdx, len ; len
  syscall ; call write()

  mov rax, 60 ; exit
  mov rdi, 0 ; exit code
  syscall ; call exit()

 

This produces a 456 byte binary with 39 bytes of code and 4 bytes of data:

$ make
nasm -f elf64 -o hi.o hi.s
ld -s -static -nostdinc -o hi hi.o
$ wc -c ./hi
456 ./hi
$ objdump -hd -Mintel ./hi
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000027  0000000000400080  0000000000400080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       00000004  00000000004000a8  00000000004000a8  000000a8  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA

Disassembly of section .text:

0000000000400080 <.text>:
  400080:	b8 01 00 00 00       	mov    eax,0x1
  400085:	bf 01 00 00 00       	mov    edi,0x1
  40008a:	48 be a8 00 40 00 00 	movabs rsi,0x4000a8
  400091:	00 00 00
  400094:	ba 04 00 00 00       	mov    edx,0x4
  400099:	0f 05                	syscall
  40009b:	b8 3c 00 00 00       	mov    eax,0x3c
  4000a0:	bf 00 00 00 00       	mov    edi,0x0
  4000a5:	0f 05                	syscall

 

First, we replace the unnecessary 5 byte instructions with smaller equivalents:

diff --git a/src/asm-naive/hi.s b/src/asm-naive/hi.s
index 9d17cab..3694091 100644
--- a/src/asm-naive/hi.s
+++ b/src/asm-naive/hi.s
@@ -14,12 +14,12 @@ section .rodata
 section .text

 _start:
-  mov rax, 1 ; write
-  mov rdi, 1 ; fd
+  inc al ; write
+  inc edi; fd
   mov rsi, hi ; msg
-  mov rdx, len ; len
+  mov dl, len ; len
   syscall ; call write()

-  mov rax, 60 ; exit
-  mov rdi, 0 ; exit code
+  mov al, 60 ; exit
+  xor edi, edi ; exit code
   syscall ; call exit()

 

Notes:

  • inc al works because Linux zeros registers on process init.
  • inc edi is 2 bytes. Another 2 byte option is mov edi, eax. The other candidates (inc dil, inc di, mov dil, al, and mov di, ax) are all 3 bytes.
  • xor edi, edi is 2 bytes. The other candidates (mov dil, 0, mov di, 0, mov edi, 0, xor dil, dil, xor di, di, and xor rdi, rdi) are all 3-5 bytes.

These changes shrink the binary size to 440 bytes, with 24 bytes of code and 4 bytes of data:

$ make
nasm -f elf64 -o hi.o hi.s
ld -s -static -nostdinc -o hi hi.o
$ wc -c ./hi
440 ./hi
$ objdump -hd -Mintel ./hi
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000018  0000000000400080  0000000000400080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       00000004  0000000000400098  0000000000400098  00000098  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA

Disassembly of section .text:

0000000000400080 <.text>:
  400080:	fe c0                	inc    al
  400082:	ff c7                	inc    edi
  400084:	48 be 98 00 40 00 00 	movabs rsi,0x400098
  40008b:	00 00 00
  40008e:	b2 04                	mov    dl,0x4
  400090:	0f 05                	syscall
  400092:	b0 3c                	mov    al,0x3c
  400094:	31 ff                	xor    edi,edi
  400096:	0f 05                	syscall

 

The code is now 24 bytes, of which 10 are one large mov instruction.

We can drop 2 bytes of code, 4 bytes of data, and the .rodata section by doing the following:

  1. Remove mov rsi, str (-10 bytes, good riddance).
  2. Drop the .rodata section (-4 bytes of data plus .rodata section overhead).
  3. Encode "hi!\n" as a 32-bit integer and push it to the stack (+5 bytes). Hint: "hi!\n" = 68 69 21 0a, encoded as 0x0a216968 plus one byte for push.
  4. Copy rsp to rsi (+3 bytes). This gives write a valid pointer.

Here’s the result:

bits 64

; "hi!\n", encoded as 32-bit little-endian int
str: equ 0x0a216968

section .text
global _start
_start:
  push dword str  ; push str (68 68 69 21 0a)

  inc al          ; write() (fe c0)
  inc edi         ; fd (ff c7)
  mov rsi, rsp    ; msg (48 89 e6)
  mov dl, 4       ; len (b2 04)
  syscall         ; call write() (0f 05)

  mov al, 60      ; exit() (b0 3c)
  xor edi, edi    ; exit code (31 ff)
  syscall         ; call exit() (0f 05)

 

This produces a 360 byte binary with 22 bytes of code and no data section:

$ make
nasm -f elf64 -o hi.o hi.s
ld -s -static -nostdinc -o hi hi.o
$ ./hi
hi!
$ wc -c ./hi
360 ./hi
$ objdump -h ./hi
...
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000016  0000000000400080  0000000000400080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

 

This is the smallest legitimate assembly implementation that I could cook up. It’s available in the companion GitHub repository and shown in the results as asm-opt.

Dirty Tricks

In Tiny ELF Files: Revisited in 2021, Nathan Otterness created a 114 byte static x86-64 Linux binary by overlapping portions of the ELF header with the program header, then embedding the code in unverified* gaps of the ELF header.

(* Unverified by Linux, that is. Junk in these fields causes readelf and objdump give these binaries the stink eye, as we’ll see shortly).

Nathan also created a handy table showing which ELF header bytes are unverified by Linux. In particular, there are two unverified 12 byte regions at offsets 4 and 40 which could store our 22 bytes of code.

I reordered the code and divided it into into two chunks:

  • code_0: First 10 bytes, plus a two byte jump to code_1
  • code_1: Remaining 12 bytes.

Here’s the assembly for the two chunks:

; entry point
; (10 bytes of code plus a 2 byte jump to code_1)
code_0:
  push dword str  ; push string onto stack (68 68 69 21 0a)
  inc al          ; write() (fe c0)
  mov rsi, rsp    ; str (48 89 e6)
  jmp code_1      ; jump to next chunk (eb 18)

; ...

; second code chunk
; (12 bytes)
code_1:
  inc edi         ; fd (89 c7)
  mov dl, 4       ; len (b2 04)
  syscall         ; call write() (0f 05)

  mov al, 60      ; exit() (b0 3c)
  xor edi, edi    ; 0 exit code (31 ff)
  syscall         ; call exit() (0f 05)

 

These changes shrink the binary to 114 bytes. Linux will still happily execute the binary, but readelf, objdump, and file can’t make sense of it:

$ make
nasm -f bin -o hi hi.s
chmod a+x hi
$ ./hi
hi!
$ wc -c ./hi
114 ./hi
$ objdump -hd -Mintel ./hi
objdump: ./hi: file format not recognized
$ readelf -SW ./hi
There are 65329 section headers, starting at offset 0x3a:
readelf: Warning: The e_shentsize field in the ELF header is larger than the size of an ELF section header
readelf: Error: Reading 1014951344 bytes extends past end of file for section headers
readelf: Error: Too many program headers - 0x50f - the file is not that big
$ file ./hi
./hi: ELF, unknown class 104
$ hd ./hi
00000000  7f 45 4c 46 68 68 69 21  0a fe c0 48 89 e6 eb 18  |.ELFhhi!...H....|
00000010  02 00 3e 00 01 00 00 00  04 80 02 00 00 00 00 00  |..>.............|
00000020  3a 00 00 00 00 00 00 00  ff c7 b2 04 0f 05 b0 3c  |:..............<|
00000030  31 ff 0f 05 40 00 38 00  01 00 01 00 00 00 05 00  |1...@.8.........|
00000040  00 00 00 00 00 00 00 00  00 00 00 80 02 00 00 00  |................|
00000050  00 00 00 00 00 00 00 00  00 00 72 00 00 00 00 00  |..........r.....|
00000060  00 00 72 00 00 00 00 00  00 00 00 00 00 00 00 00  |..r.............|
00000070  00 00                                             |..|
00000072

 

It’ll even run as a Docker image:

$ cat Dockerfile
FROM scratch
COPY ./hi /hi
ENTRYPOINT ["/hi"]
$ docker build -t zoinks .
Sending build context to Docker daemon  8.704kB
Step 1/3 : FROM scratch
 --->
Step 2/3 : COPY ./hi /hi
 ---> 336acd7e2d94
Step 3/3 : ENTRYPOINT ["/hi"]
 ---> Running in e20eca61de44
Removing intermediate container e20eca61de44
 ---> 297e5d7db5f8
Successfully built 297e5d7db5f8
Successfully tagged zoinks:latest
$ docker images zoinks --format '{{.Size}}'
114B
$ docker run --rm -it zoinks
hi!
$

 

This glorious monstrosity is included in the companion repository and shown in the results as asm-elf.

If you enjoyed this post, you may also like:

Update (2022-01-02): Shorten, fix typos, improve grammar.

Tiny Binaries

December 31, 2021

Out of curiousity I experimented with building the smallest possible static x86-64 Linux binaries in several programming languages.

Each binary does the following:

  1. Print hi! and a newline to standard output.
  2. Return an exit code of 0.

I tested Assembly, C, Go, and Rust with various optimization and build option combinations.

Here’s a plot of the results (note: log scale X axis):

All Static Binary Sizes

All Static Binary Sizes

Here’s a plot of the smallest static binary sizes (<1k, linear scale X axis):

Tiny Static Binary Sizes (&lt;1k)

Tiny Static Binary Sizes (<1k)

Full Disclosure: asm-opt is the smallest legitimate result; asm-elf uses dirty tricks from Tiny ELF Files: Revisited in 2021.

Source code, build instructions, a CSV of results, and additional details are available in the companion GitHub repository.

Update (2022-01-01): See Tiny Binaries: Assembly Optimization for an explanation of the assembly results.

Update (2022-02-24): Rust 1.59.0 was released today and makes it significantly easier to create stripped binaries.

Update (2022-03-02): Added a follow up post with Go 1.18rc1, Rust 1.59, and TinyGo 0.22.

Mathyd: Easy TeX to SVG

December 5, 2021

A few weeks ago I released Mathyd, a Docker image containing an HTTP daemon which converts TeX to SVG.

Mathyd includes a minimal command-line client which reads TeX from standard input and prints the generated SVG to standard output, like so:

# set URL and HMAC secret key
# (note: replace value of MATHYD_HMAC_KEY with your own randomly
# generated one)
export MATHYD_URL="http://whatever.example.com:3000/"
export MATHYD_HMAC_KEY="2dhXA3HTmfEMq2d5"

# render output
bin/mathy < cubic.tex > cubic.svg

 

Given this input file, the command above produces the following result:

The Cubic Formula, rendered by Mathyd.

The Cubic Formula, rendered by Mathyd.

Installation

You can install and run the Mathyd Docker image with a single command:

# run mathyd as a daemon on port 3000
# (note: replace value of MATHYD_HMAC_KEY with your own randomly
# generated one)
docker run --rm -d -e MATHYD_HMAC_KEY="2dhXA3HTmfEMq2d5" -p 3000:3000 pablotron/mathyd:latest

 

Notes:

  • Be sure to generate your own HMAC secret key rather than reusing the key from the examples above.
  • Don’t expose Mathyd via a publicly-accessible URL; it does not support TLS and MathJax may use a lot of memory for large input files. If you really do want to do this, then you’ll need to proxy the Mathyd endpoint behind Apache or nginx on an authenticated, TLS-encrypted URL.

Technical Details

Under the hood, Mathyd is just:

  1. a container running an Express HTTP daemon that exposes a single endpoint that accepts a PUT request containing:

    • A JSON-encoded body of input parameters.
    • A hex-encoded, SHA-256 HMAC of the body and the HMAC secret key in the x-mathyd-hmac-sha256 header.

    The endpoint does the following:

    1. Verifies the body HMAC.
    2. Parses the JSON body and extracts the input parameters, including the source TeX.
    3. Converts the input TeX to SVG (via MathJax).
    4. Returns a JSON-encoded response containing the generated SVG.
  2. A command-line client, written in Ruby, which:

    1. Reads TeX from standard input and serializes it as JSON.
    2. Sends a PUT request to the Mathyd daemon and parses the response.
    3. Extracts the generated SVG from the response and writes it to standard output.

Rationale

I wanted an easy way to generate static math SVGs for web pages from the command-line without installing a blizzard of dependencies and without requiring MathJax on the destination page.

I prefer TeX over other formats because it’s still the least-worst format for complex math formulas.

I prefer SVGs over bitmap images whenever possible because:

  • SVGs scale to any screen size and resolution. This is particularly useful for responsive design.
  • SVGs are supported by all modern browsers, including mobile browsers.

Fun fact: even the animated logo for this page is an SVG.

Note: If you want a web interface to noodle around with TeX, check out Mathy instead.

The Birthday Paradox

November 11, 2021

How many people have to be in a room before the probability that there is a shared birthday is greater than 50%?

This is called the Birthday Problem, and the solution is known as the birthday paradox. It is interesting because the answer is counterintuitive and the ramifications affect the security of cryptographic hash algorithms.

The explanation is a bit long for a blog post, so I wrote a full article:

The Birthday Paradox

Archived Posts...
© 1998-2022 Paul Duncan