diff --git a/src/common/framework/data_cache.ts b/src/common/framework/data_cache.ts
index 6f6e80288a8f..be5bde8e224b 100644
--- a/src/common/framework/data_cache.ts
+++ b/src/common/framework/data_cache.ts
@@ -3,15 +3,64 @@
  * expensive to build using a two-level cache (in-memory, pre-computed file).
  */
 
+import { assert } from '../util/util.js';
+
 interface DataStore {
-  load(path: string): Promise<string>;
+  load(path: string): Promise<Uint8Array>;
 }
 
 /** Logger is a basic debug logger function */
 export type Logger = (s: string) => void;
 
-/** DataCache is an interface to a data store used to hold cached data */
+/**
+ * DataCacheNode represents a single cache entry in the LRU DataCache.
+ * DataCacheNode is a doubly linked list, so that least-recently-used entries can be removed, and
+ * cache hits can move the node to the front of the list.
+ */
+class DataCacheNode {
+  public constructor(path: string, data: unknown) {
+    this.path = path;
+    this.data = data;
+  }
+
+  /** insertAfter() re-inserts this node in the doubly-linked list after @p prev */
+  public insertAfter(prev: DataCacheNode) {
+    this.unlink();
+    this.next = prev.next;
+    this.prev = prev;
+    prev.next = this;
+    if (this.next) {
+      this.next.prev = this;
+    }
+  }
+
+  /** unlink() removes this node from the doubly-linked list */
+  public unlink() {
+    const prev = this.prev;
+    const next = this.next;
+    if (prev) {
+      prev.next = next;
+    }
+    if (next) {
+      next.prev = prev;
+    }
+    this.prev = null;
+    this.next = null;
+  }
+
+  public readonly path: string; // The file path this node represents
+  public readonly data: unknown; // The deserialized data for this node
+  public prev: DataCacheNode | null = null; // The previous node in the doubly-linked list
+  public next: DataCacheNode | null = null; // The next node in the doubly-linked list
+}
+
+/** DataCache is an interface to a LRU-cached data store used to hold data cached by path */
 export class DataCache {
+  public constructor() {
+    this.lruHeadNode.next = this.lruTailNode;
+    this.lruTailNode.prev = this.lruHeadNode;
+  }
+
   /** setDataStore() sets the backing data store used by the data cache */
   public setStore(dataStore: DataStore) {
     this.dataStore = dataStore;
@@ -28,17 +77,20 @@ export class DataCache {
    * building the data and storing it in the cache.
    */
   public async fetch<Data>(cacheable: Cacheable<Data>): Promise<Data> {
-    // First check the in-memory cache
-    let data = this.cache.get(cacheable.path);
-    if (data !== undefined) {
-      this.log('in-memory cache hit');
-      return Promise.resolve(data as Data);
+    {
+      // First check the in-memory cache
+      const node = this.cache.get(cacheable.path);
+      if (node !== undefined) {
+        this.log('in-memory cache hit');
+        node.insertAfter(this.lruHeadNode);
+        return Promise.resolve(node.data as Data);
+      }
     }
     this.log('in-memory cache miss');
     // In in-memory cache miss.
     // Next, try the data store.
     if (this.dataStore !== null && !this.unavailableFiles.has(cacheable.path)) {
-      let serialized: string | undefined;
+      let serialized: Uint8Array | undefined;
       try {
         serialized = await this.dataStore.load(cacheable.path);
         this.log('loaded serialized');
@@ -49,16 +101,37 @@ export class DataCache {
       }
       if (serialized !== undefined) {
         this.log(`deserializing`);
-        data = cacheable.deserialize(serialized);
-        this.cache.set(cacheable.path, data);
-        return data as Data;
+        const data = cacheable.deserialize(serialized);
+        this.addToCache(cacheable.path, data);
+        return data;
       }
     }
     // Not found anywhere. Build the data, and cache for future lookup.
     this.log(`cache: building (${cacheable.path})`);
-    data = await cacheable.build();
-    this.cache.set(cacheable.path, data);
-    return data as Data;
+    const data = await cacheable.build();
+    this.addToCache(cacheable.path, data);
+    return data;
+  }
+
+  /**
+   * addToCache() creates a new node for @p path and @p data, inserting the new node at the front of
+   * the doubly-linked list. If the number of entries in the cache exceeds this.maxCount, then the
+   * least recently used entry is evicted
+   * @param path the file path for the data
+   * @param data the deserialized data
+   */
+  private addToCache(path: string, data: unknown) {
+    if (this.cache.size >= this.maxCount) {
+      const toEvict = this.lruTailNode.prev;
+      assert(toEvict !== null);
+      toEvict.unlink();
+      this.cache.delete(toEvict.path);
+      this.log(`evicting ${toEvict.path}`);
+    }
+    const node = new DataCacheNode(path, data);
+    node.insertAfter(this.lruHeadNode);
+    this.cache.set(path, node);
+    this.log(`added ${path}. new count: ${this.cache.size}`);
   }
 
   private log(msg: string) {
@@ -67,7 +140,12 @@ export class DataCache {
     }
   }
 
-  private cache = new Map<string, unknown>();
+  // Max number of entries in the cache before LRU entries are evicted.
+  private readonly maxCount = 4;
+
+  private cache = new Map<string, DataCacheNode>();
+  private lruHeadNode = new DataCacheNode('', null); // placeholder node (no path or data)
+  private lruTailNode = new DataCacheNode('', null); // placeholder node (no path or data)
   private unavailableFiles = new Set<string>();
   private dataStore: DataStore | null = null;
   private debugLogger: Logger | null = null;
@@ -107,14 +185,13 @@ export interface Cacheable<Data> {
   build(): Promise<Data>;
 
   /**
-   * serialize() transforms `data` to a string (usually JSON encoded) so that it
-   * can be stored in a text cache file.
+   * serialize() encodes `data` to a binary representation so that it can be stored in a cache file.
    */
-  serialize(data: Data): string;
+  serialize(data: Data): Uint8Array;
 
   /**
-   * deserialize() is the inverse of serialize(), transforming the string back
-   * to the Data object.
+   * deserialize() is the inverse of serialize(), decoding the binary representation back to a Data
+   * object.
    */
-  deserialize(serialized: string): Data;
+  deserialize(binary: Uint8Array): Data;
 }
diff --git a/src/common/runtime/cmdline.ts b/src/common/runtime/cmdline.ts
index 1fb39b68ce8c..44a73fb38b34 100644
--- a/src/common/runtime/cmdline.ts
+++ b/src/common/runtime/cmdline.ts
@@ -135,8 +135,8 @@ Did you remember to build with code coverage instrumentation enabled?`
 if (dataPath !== undefined) {
   dataCache.setStore({
     load: (path: string) => {
-      return new Promise<string>((resolve, reject) => {
-        fs.readFile(`${dataPath}/${path}`, 'utf8', (err, data) => {
+      return new Promise<Uint8Array>((resolve, reject) => {
+        fs.readFile(`${dataPath}/${path}`, (err, data) => {
           if (err !== null) {
             reject(err.message);
           } else {
diff --git a/src/common/runtime/server.ts b/src/common/runtime/server.ts
index 8903d5a53293..8310784e3a2c 100644
--- a/src/common/runtime/server.ts
+++ b/src/common/runtime/server.ts
@@ -133,8 +133,8 @@ Did you remember to build with code coverage instrumentation enabled?`
 if (dataPath !== undefined) {
   dataCache.setStore({
     load: (path: string) => {
-      return new Promise<string>((resolve, reject) => {
-        fs.readFile(`${dataPath}/${path}`, 'utf8', (err, data) => {
+      return new Promise<Uint8Array>((resolve, reject) => {
+        fs.readFile(`${dataPath}/${path}`, (err, data) => {
           if (err !== null) {
             reject(err.message);
           } else {
diff --git a/src/common/runtime/standalone.ts b/src/common/runtime/standalone.ts
index 4ec300d30684..be5887c1721e 100644
--- a/src/common/runtime/standalone.ts
+++ b/src/common/runtime/standalone.ts
@@ -84,7 +84,7 @@ dataCache.setStore({
     if (!response.ok) {
       return Promise.reject(response.statusText);
     }
-    return await response.text();
+    return new Uint8Array(await response.arrayBuffer());
   },
 });
 
diff --git a/src/common/tools/gen_cache.ts b/src/common/tools/gen_cache.ts
index 4d1a9da726da..ce0854aa2046 100644
--- a/src/common/tools/gen_cache.ts
+++ b/src/common/tools/gen_cache.ts
@@ -87,8 +87,8 @@ const outRootDir = nonFlagsArgs[2];
 
 dataCache.setStore({
   load: (path: string) => {
-    return new Promise<string>((resolve, reject) => {
-      fs.readFile(`data/${path}`, 'utf8', (err, data) => {
+    return new Promise<Uint8Array>((resolve, reject) => {
+      fs.readFile(`data/${path}`, (err, data) => {
         if (err !== null) {
           reject(err.message);
         } else {
@@ -180,7 +180,7 @@ and
             const data = await cacheable.build();
             const serialized = cacheable.serialize(data);
             fs.mkdirSync(path.dirname(outPath), { recursive: true });
-            fs.writeFileSync(outPath, serialized);
+            fs.writeFileSync(outPath, serialized, 'binary');
             break;
           }
           case 'list': {
diff --git a/src/unittests/serialization.spec.ts b/src/unittests/serialization.spec.ts
index 25aa44561fab..76ac1f715545 100644
--- a/src/unittests/serialization.spec.ts
+++ b/src/unittests/serialization.spec.ts
@@ -7,6 +7,7 @@ import {
   deserializeExpectation,
   serializeExpectation,
 } from '../webgpu/shader/execution/expression/case_cache.js';
+import BinaryStream from '../webgpu/util/binary_stream.js';
 import {
   anyOf,
   deserializeComparator,
@@ -206,11 +207,14 @@ g.test('value').fn(t => {
       f32
     ),
   ]) {
-    const serialized = serializeValue(value);
-    const deserialized = deserializeValue(serialized);
+    const s = new BinaryStream(new Uint8Array(1024));
+    serializeValue(s, value);
+    const d = new BinaryStream(s.buffer());
+    const deserialized = deserializeValue(d);
     t.expect(
       objectEquals(value, deserialized),
-      `value ${value} -> serialize -> deserialize -> ${deserialized}`
+      `${value.type} ${value} -> serialize -> deserialize -> ${deserialized}
+buffer: ${s.buffer()}`
     );
   }
 });
@@ -240,8 +244,10 @@ g.test('fpinterval_f32').fn(t => {
     FP.f32.toInterval([kValue.f32.negative.subnormal.min, kValue.f32.negative.subnormal.max]),
     FP.f32.toInterval([kValue.f32.negative.infinity, kValue.f32.positive.infinity]),
   ]) {
-    const serialized = serializeFPInterval(interval);
-    const deserialized = deserializeFPInterval(serialized);
+    const s = new BinaryStream(new Uint8Array(1024));
+    serializeFPInterval(s, interval);
+    const d = new BinaryStream(s.buffer());
+    const deserialized = deserializeFPInterval(d);
     t.expect(
       objectEquals(interval, deserialized),
       `interval ${interval} -> serialize -> deserialize -> ${deserialized}`
@@ -274,8 +280,10 @@ g.test('fpinterval_f16').fn(t => {
     FP.f16.toInterval([kValue.f16.negative.subnormal.min, kValue.f16.negative.subnormal.max]),
     FP.f16.toInterval([kValue.f16.negative.infinity, kValue.f16.positive.infinity]),
   ]) {
-    const serialized = serializeFPInterval(interval);
-    const deserialized = deserializeFPInterval(serialized);
+    const s = new BinaryStream(new Uint8Array(1024));
+    serializeFPInterval(s, interval);
+    const d = new BinaryStream(s.buffer());
+    const deserialized = deserializeFPInterval(d);
     t.expect(
       objectEquals(interval, deserialized),
       `interval ${interval} -> serialize -> deserialize -> ${deserialized}`
@@ -308,8 +316,10 @@ g.test('fpinterval_abstract').fn(t => {
     FP.abstract.toInterval([kValue.f64.negative.subnormal.min, kValue.f64.negative.subnormal.max]),
     FP.abstract.toInterval([kValue.f64.negative.infinity, kValue.f64.positive.infinity]),
   ]) {
-    const serialized = serializeFPInterval(interval);
-    const deserialized = deserializeFPInterval(serialized);
+    const s = new BinaryStream(new Uint8Array(1024));
+    serializeFPInterval(s, interval);
+    const d = new BinaryStream(s.buffer());
+    const deserialized = deserializeFPInterval(d);
     t.expect(
       objectEquals(interval, deserialized),
       `interval ${interval} -> serialize -> deserialize -> ${deserialized}`
@@ -328,8 +338,10 @@ g.test('expression_expectation').fn(t => {
     // Intervals
     [FP.f32.toInterval([-8.0, 0.5]), FP.f32.toInterval([2.0, 4.0])],
   ]) {
-    const serialized = serializeExpectation(expectation);
-    const deserialized = deserializeExpectation(serialized);
+    const s = new BinaryStream(new Uint8Array(1024));
+    serializeExpectation(s, expectation);
+    const d = new BinaryStream(s.buffer());
+    const deserialized = deserializeExpectation(d);
     t.expect(
       objectEquals(expectation, deserialized),
       `expectation ${expectation} -> serialize -> deserialize -> ${deserialized}`
@@ -356,8 +368,10 @@ g.test('anyOf').fn(t => {
         testCases: [f32(0), f32(10), f32(122), f32(123), f32(124), f32(200)],
       },
     ]) {
-      const serialized = serializeComparator(c.comparator);
-      const deserialized = deserializeComparator(serialized);
+      const s = new BinaryStream(new Uint8Array(1024));
+      serializeComparator(s, c.comparator);
+      const d = new BinaryStream(s.buffer());
+      const deserialized = deserializeComparator(d);
       for (const val of c.testCases) {
         const got = deserialized.compare(val);
         const expect = c.comparator.compare(val);
@@ -382,8 +396,10 @@ g.test('skipUndefined').fn(t => {
         testCases: [f32(0), f32(10), f32(122), f32(123), f32(124), f32(200)],
       },
     ]) {
-      const serialized = serializeComparator(c.comparator);
-      const deserialized = deserializeComparator(serialized);
+      const s = new BinaryStream(new Uint8Array(1024));
+      serializeComparator(s, c.comparator);
+      const d = new BinaryStream(s.buffer());
+      const deserialized = deserializeComparator(d);
       for (const val of c.testCases) {
         const got = deserialized.compare(val);
         const expect = c.comparator.compare(val);
diff --git a/src/webgpu/shader/execution/expression/case_cache.ts b/src/webgpu/shader/execution/expression/case_cache.ts
index e0aaa377192f..88f4a48df4c8 100644
--- a/src/webgpu/shader/execution/expression/case_cache.ts
+++ b/src/webgpu/shader/execution/expression/case_cache.ts
@@ -1,164 +1,125 @@
 import { Cacheable, dataCache } from '../../../../common/framework/data_cache.js';
 import { unreachable } from '../../../../common/util/util.js';
-import {
-  SerializedComparator,
-  deserializeComparator,
-  serializeComparator,
-} from '../../../util/compare.js';
+import BinaryStream from '../../../util/binary_stream.js';
+import { deserializeComparator, serializeComparator } from '../../../util/compare.js';
 import {
   Scalar,
   Vector,
   serializeValue,
-  SerializedValue,
   deserializeValue,
   Matrix,
+  Value,
 } from '../../../util/conversion.js';
 import {
   deserializeFPInterval,
   FPInterval,
-  SerializedFPInterval,
   serializeFPInterval,
 } from '../../../util/floating_point.js';
 import { flatten2DArray, unflatten2DArray } from '../../../util/math.js';
 
 import { Case, CaseList, Expectation, isComparator } from './expression.js';
 
-/**
- * SerializedExpectationValue holds the serialized form of an Expectation when
- * the Expectation is a Value
- * This form can be safely encoded to JSON.
- */
-type SerializedExpectationValue = {
-  kind: 'value';
-  value: SerializedValue;
-};
-
-/**
- * SerializedExpectationInterval holds the serialized form of an Expectation when
- * the Expectation is an Interval
- * This form can be safely encoded to JSON.
- */
-type SerializedExpectationInterval = {
-  kind: 'interval';
-  value: SerializedFPInterval;
-};
-
-/**
- * SerializedExpectationIntervals holds the serialized form of an Expectation when
- * the Expectation is a list of Intervals
- * This form can be safely encoded to JSON.
- */
-type SerializedExpectationIntervals = {
-  kind: 'intervals';
-  value: SerializedFPInterval[];
-};
-
-/**
- * SerializedExpectation2DIntervalArray holds the serialized form of an
- * Expectation when the Expectation is a 2d array of Intervals. The array is
- * flattened to a 1D array for storage.
- * This form can be safely encoded to JSON.
- */
-type SerializedExpectation2DIntervalArray = {
-  kind: '2d-interval-array';
-  cols: number;
-  rows: number;
-  value: SerializedFPInterval[];
-};
-
-/**
- * SerializedExpectationValue holds the serialized form of an Expectation when
- * the Expectation is a Comparator
- * This form can be safely encoded to JSON.
- */
-type SerializedExpectationComparator = {
-  kind: 'comparator';
-  value: SerializedComparator;
-};
-
-/**
- * SerializedExpectation holds the serialized form of an Expectation.
- * This form can be safely encoded to JSON.
- */
-export type SerializedExpectation =
-  | SerializedExpectationValue
-  | SerializedExpectationInterval
-  | SerializedExpectationIntervals
-  | SerializedExpectation2DIntervalArray
-  | SerializedExpectationComparator;
+enum SerializedExpectationKind {
+  Value,
+  Interval,
+  Interval1DArray,
+  Interval2DArray,
+  Array,
+  Comparator,
+}
 
-/** serializeExpectation() converts an Expectation to a SerializedExpectation */
-export function serializeExpectation(e: Expectation): SerializedExpectation {
+/** serializeExpectation() serializes an Expectation to a BinaryStream */
+export function serializeExpectation(s: BinaryStream, e: Expectation) {
   if (e instanceof Scalar || e instanceof Vector || e instanceof Matrix) {
-    return { kind: 'value', value: serializeValue(e) };
+    s.writeU8(SerializedExpectationKind.Value);
+    serializeValue(s, e);
+    return;
   }
   if (e instanceof FPInterval) {
-    return { kind: 'interval', value: serializeFPInterval(e) };
+    s.writeU8(SerializedExpectationKind.Interval);
+    serializeFPInterval(s, e);
+    return;
   }
   if (e instanceof Array) {
     if (e[0] instanceof Array) {
       e = e as FPInterval[][];
       const cols = e.length;
       const rows = e[0].length;
-      return {
-        kind: '2d-interval-array',
-        cols,
-        rows,
-        value: flatten2DArray(e).map(serializeFPInterval),
-      };
+      s.writeU8(SerializedExpectationKind.Interval2DArray);
+      s.writeU16(cols);
+      s.writeU16(rows);
+      s.writeArray(flatten2DArray(e), serializeFPInterval);
     } else {
       e = e as FPInterval[];
-      return { kind: 'intervals', value: e.map(serializeFPInterval) };
+      s.writeU8(SerializedExpectationKind.Interval1DArray);
+      s.writeArray(e, serializeFPInterval);
     }
+    return;
   }
   if (isComparator(e)) {
-    return { kind: 'comparator', value: serializeComparator(e) };
+    s.writeU8(SerializedExpectationKind.Comparator);
+    serializeComparator(s, e);
+    return;
   }
   unreachable(`cannot serialize Expectation ${e}`);
 }
 
-/** deserializeExpectation() converts a SerializedExpectation to a Expectation */
-export function deserializeExpectation(data: SerializedExpectation): Expectation {
-  switch (data.kind) {
-    case 'value':
-      return deserializeValue(data.value);
-    case 'interval':
-      return deserializeFPInterval(data.value);
-    case 'intervals':
-      return data.value.map(deserializeFPInterval);
-    case '2d-interval-array':
-      return unflatten2DArray(data.value.map(deserializeFPInterval), data.cols, data.rows);
-    case 'comparator':
-      return deserializeComparator(data.value);
+/** deserializeExpectation() deserializes an Expectation from a BinaryStream */
+export function deserializeExpectation(s: BinaryStream): Expectation {
+  const kind = s.readU8();
+  switch (kind) {
+    case SerializedExpectationKind.Value: {
+      return deserializeValue(s);
+    }
+    case SerializedExpectationKind.Interval: {
+      return deserializeFPInterval(s);
+    }
+    case SerializedExpectationKind.Interval1DArray: {
+      return s.readArray(deserializeFPInterval);
+    }
+    case SerializedExpectationKind.Interval2DArray: {
+      const cols = s.readU16();
+      const rows = s.readU16();
+      return unflatten2DArray(s.readArray(deserializeFPInterval), cols, rows);
+    }
+    case SerializedExpectationKind.Comparator: {
+      return deserializeComparator(s);
+    }
+    default: {
+      unreachable(`invalid serialized expectation kind: ${kind}`);
+    }
   }
 }
 
-/**
- * SerializedCase holds the serialized form of a Case.
- * This form can be safely encoded to JSON.
- */
-export type SerializedCase = {
-  input: SerializedValue | SerializedValue[];
-  expected: SerializedExpectation;
-};
-
-/** serializeCase() converts an Case to a SerializedCase */
-export function serializeCase(c: Case): SerializedCase {
-  return {
-    input: c.input instanceof Array ? c.input.map(v => serializeValue(v)) : serializeValue(c.input),
-    expected: serializeExpectation(c.expected),
-  };
+/** serializeCase() serializes a Case to a BinaryStream */
+export function serializeCase(s: BinaryStream, c: Case) {
+  s.writeCond(c.input instanceof Array, {
+    if_true: () => {
+      // c.input is array
+      s.writeArray(c.input as Value[], serializeValue);
+    },
+    if_false: () => {
+      // c.input is not array
+      serializeValue(s, c.input as Value);
+    },
+  });
+  serializeExpectation(s, c.expected);
 }
 
-/** serializeCase() converts an SerializedCase to a Case */
-export function deserializeCase(data: SerializedCase): Case {
-  return {
-    input:
-      data.input instanceof Array
-        ? data.input.map(v => deserializeValue(v))
-        : deserializeValue(data.input),
-    expected: deserializeExpectation(data.expected),
-  };
+/** deserializeCase() deserializes a Case from a BinaryStream */
+export function deserializeCase(s: BinaryStream): Case {
+  const input = s.readCond({
+    if_true: () => {
+      // c.input is array
+      return s.readArray(deserializeValue);
+    },
+    if_false: () => {
+      // c.input is not array
+      return deserializeValue(s);
+    },
+  });
+  const expected = deserializeExpectation(s);
+  return { input, expected };
 }
 
 /** CaseListBuilder is a function that builds a CaseList */
@@ -176,7 +137,7 @@ export class CaseCache implements Cacheable<Record<string, CaseList>> {
    * @param builders a Record of case-list name to case-list builder.
    */
   constructor(name: string, builders: Record<string, CaseListBuilder>) {
-    this.path = `webgpu/shader/execution/case-cache/${name}.json`;
+    this.path = `webgpu/shader/execution/case-cache/${name}.bin`;
     this.builders = builders;
   }
 
@@ -203,23 +164,28 @@ export class CaseCache implements Cacheable<Record<string, CaseList>> {
    * serialize() implements the Cacheable.serialize interface.
    * @returns the serialized data.
    */
-  serialize(data: Record<string, CaseList>): string {
-    const serialized: Record<string, SerializedCase[]> = {};
+  serialize(data: Record<string, CaseList>): Uint8Array {
+    const maxSize = 32 << 20; // 32MB - max size for a file
+    const s = new BinaryStream(new Uint8Array(maxSize));
+    s.writeU32(Object.keys(data).length);
     for (const name in data) {
-      serialized[name] = data[name].map(c => serializeCase(c));
+      s.writeString(name);
+      s.writeArray(data[name], serializeCase);
     }
-    return JSON.stringify(serialized);
+    return s.buffer();
   }
 
   /**
    * deserialize() implements the Cacheable.deserialize interface.
    * @returns the deserialize data.
    */
-  deserialize(serialized: string): Record<string, CaseList> {
-    const data = JSON.parse(serialized) as Record<string, SerializedCase[]>;
+  deserialize(buffer: Uint8Array): Record<string, CaseList> {
+    const s = new BinaryStream(buffer);
     const casesByName: Record<string, CaseList> = {};
-    for (const name in data) {
-      const cases = data[name].map(caseData => deserializeCase(caseData));
+    const numRecords = s.readU32();
+    for (let i = 0; i < numRecords; i++) {
+      const name = s.readString();
+      const cases = s.readArray(deserializeCase);
       casesByName[name] = cases;
     }
     return casesByName;
diff --git a/src/webgpu/util/binary_stream.ts b/src/webgpu/util/binary_stream.ts
new file mode 100644
index 000000000000..1a17f4524d58
--- /dev/null
+++ b/src/webgpu/util/binary_stream.ts
@@ -0,0 +1,283 @@
+import { assert } from '../../common/util/util.js';
+import { Float16Array } from '../../external/petamoriken/float16/float16.js';
+
+import { align } from './math.js';
+
+/**
+ * BinaryStream is a utility to efficiently encode and decode numbers to / from a Uint8Array.
+ * BinaryStream uses a number of internal typed arrays to avoid small array allocations when reading
+ * and writing.
+ */
+export default class BinaryStream {
+  /**
+   * Constructor
+   * @param buffer the buffer to read from / write to. Array length must be a multiple of 8 bytes.
+   */
+  constructor(buffer: Uint8Array) {
+    this.offset = 0;
+    this.u8 = buffer;
+    this.u16 = new Uint16Array(this.u8.buffer);
+    this.u32 = new Uint32Array(this.u8.buffer);
+    this.i8 = new Int8Array(this.u8.buffer);
+    this.i16 = new Int16Array(this.u8.buffer);
+    this.i32 = new Int32Array(this.u8.buffer);
+    this.f16 = new Float16Array(this.u8.buffer);
+    this.f32 = new Float32Array(this.u8.buffer);
+    this.f64 = new Float64Array(this.u8.buffer);
+  }
+
+  /** buffer() returns the stream's buffer sliced to the 8-byte rounded read or write offset */
+  buffer(): Uint8Array {
+    return this.u8.slice(0, align(this.offset, 8));
+  }
+
+  /** writeBool() writes a boolean as 255 or 0 to the buffer at the next byte offset */
+  writeBool(value: boolean) {
+    this.u8[this.offset++] = value ? 255 : 0;
+  }
+
+  /** readBool() reads a boolean from the buffer at the next byte offset */
+  readBool(): boolean {
+    const val = this.u8[this.offset++];
+    assert(val === 0 || val === 255);
+    return val !== 0;
+  }
+
+  /** writeU8() writes a uint8 to the buffer at the next byte offset */
+  writeU8(value: number) {
+    this.u8[this.offset++] = value;
+  }
+
+  /** readU8() reads a uint8 from the buffer at the next byte offset */
+  readU8(): number {
+    return this.u8[this.offset++];
+  }
+
+  /** u8View() returns a Uint8Array view of the uint8 at the next byte offset */
+  u8View(): Uint8Array {
+    const at = this.offset++;
+    return new Uint8Array(this.u8.buffer, at, 1);
+  }
+
+  /** writeU16() writes a uint16 to the buffer at the next 16-bit aligned offset */
+  writeU16(value: number) {
+    this.u16[this.bumpWord(2)] = value;
+  }
+
+  /** readU16() reads a uint16 from the buffer at the next 16-bit aligned offset */
+  readU16(): number {
+    return this.u16[this.bumpWord(2)];
+  }
+
+  /** u16View() returns a Uint16Array view of the uint16 at the next 16-bit aligned offset */
+  u16View(): Uint16Array {
+    const at = this.bumpWord(2);
+    return new Uint16Array(this.u16.buffer, at * 2, 1);
+  }
+
+  /** writeU32() writes a uint32 to the buffer at the next 32-bit aligned offset */
+  writeU32(value: number) {
+    this.u32[this.bumpWord(4)] = value;
+  }
+
+  /** readU32() reads a uint32 from the buffer at the next 32-bit aligned offset */
+  readU32(): number {
+    return this.u32[this.bumpWord(4)];
+  }
+
+  /** u32View() returns a Uint32Array view of the uint32 at the next 32-bit aligned offset */
+  u32View(): Uint32Array {
+    const at = this.bumpWord(4);
+    return new Uint32Array(this.u32.buffer, at * 4, 1);
+  }
+
+  /** writeI8() writes a int8 to the buffer at the next byte offset */
+  writeI8(value: number) {
+    this.i8[this.offset++] = value;
+  }
+
+  /** readI8() reads a int8 from the buffer at the next byte offset */
+  readI8(): number {
+    return this.i8[this.offset++];
+  }
+
+  /** i8View() returns a Uint8Array view of the uint8 at the next byte offset */
+  i8View(): Int8Array {
+    const at = this.offset++;
+    return new Int8Array(this.i8.buffer, at, 1);
+  }
+
+  /** writeI16() writes a int16 to the buffer at the next 16-bit aligned offset */
+  writeI16(value: number) {
+    this.i16[this.bumpWord(2)] = value;
+  }
+
+  /** readI16() reads a int16 from the buffer at the next 16-bit aligned offset */
+  readI16(): number {
+    return this.i16[this.bumpWord(2)];
+  }
+
+  /** i16View() returns a Int16Array view of the uint16 at the next 16-bit aligned offset */
+  i16View(): Int16Array {
+    const at = this.bumpWord(2);
+    return new Int16Array(this.i16.buffer, at * 2, 1);
+  }
+
+  /** writeI32() writes a int32 to the buffer at the next 32-bit aligned offset */
+  writeI32(value: number) {
+    this.i32[this.bumpWord(4)] = value;
+  }
+
+  /** readI32() reads a int32 from the buffer at the next 32-bit aligned offset */
+  readI32(): number {
+    return this.i32[this.bumpWord(4)];
+  }
+
+  /** i32View() returns a Int32Array view of the uint32 at the next 32-bit aligned offset */
+  i32View(): Int32Array {
+    const at = this.bumpWord(4);
+    return new Int32Array(this.i32.buffer, at * 4, 1);
+  }
+
+  /** writeF16() writes a float16 to the buffer at the next 16-bit aligned offset */
+  writeF16(value: number) {
+    this.f16[this.bumpWord(2)] = value;
+  }
+
+  /** readF16() reads a float16 from the buffer at the next 16-bit aligned offset */
+  readF16(): number {
+    return this.f16[this.bumpWord(2)];
+  }
+
+  /** f16View() returns a Float16Array view of the uint16 at the next 16-bit aligned offset */
+  f16View(): Float16Array {
+    const at = this.bumpWord(2);
+    return new Float16Array(this.f16.buffer, at * 2, 1);
+  }
+
+  /** writeF32() writes a float32 to the buffer at the next 32-bit aligned offset */
+  writeF32(value: number) {
+    this.f32[this.bumpWord(4)] = value;
+  }
+
+  /** readF32() reads a float32 from the buffer at the next 32-bit aligned offset */
+  readF32(): number {
+    return this.f32[this.bumpWord(4)];
+  }
+
+  /** f32View() returns a Float32Array view of the uint32 at the next 32-bit aligned offset */
+  f32View(): Float32Array {
+    const at = this.bumpWord(4);
+    return new Float32Array(this.f32.buffer, at * 4, 1);
+  }
+
+  /** writeF64() writes a float64 to the buffer at the next 64-bit aligned offset */
+  writeF64(value: number) {
+    this.f64[this.bumpWord(8)] = value;
+  }
+
+  /** readF64() reads a float64 from the buffer at the next 64-bit aligned offset */
+  readF64(): number {
+    return this.f64[this.bumpWord(8)];
+  }
+
+  /** f64View() returns a Float64Array view of the uint64 at the next 64-bit aligned offset */
+  f64View(): Float64Array {
+    const at = this.bumpWord(8);
+    return new Float64Array(this.f64.buffer, at * 8, 1);
+  }
+
+  /**
+   * writeString() writes a length-prefixed UTF-16 string to the buffer at the next 32-bit aligned
+   * offset
+   */
+  writeString(value: string) {
+    this.writeU32(value.length);
+    for (let i = 0; i < value.length; i++) {
+      this.writeU16(value.charCodeAt(i));
+    }
+  }
+
+  /**
+   * readString() writes a length-prefixed UTF-16 string from the buffer at the next 32-bit aligned
+   * offset
+   */
+  readString(): string {
+    const len = this.readU32();
+    const codes = new Array<number>(len);
+    for (let i = 0; i < len; i++) {
+      codes[i] = this.readU16();
+    }
+    return String.fromCharCode(...codes);
+  }
+
+  /**
+   * writeArray() writes a length-prefixed array of T elements to the buffer at the next 32-bit
+   * aligned offset, using the provided callback to write the individual elements
+   */
+  writeArray<T>(value: readonly T[], writeElement: (s: BinaryStream, element: T) => void) {
+    this.writeU32(value.length);
+    for (const element of value) {
+      writeElement(this, element);
+    }
+  }
+
+  /**
+   * readArray() reads a length-prefixed array of T elements from the buffer at the next 32-bit
+   * aligned offset, using the provided callback to read the individual elements
+   */
+  readArray<T>(readElement: (s: BinaryStream) => T): T[] {
+    const len = this.readU32();
+    const array = new Array<T>(len);
+    for (let i = 0; i < len; i++) {
+      array[i] = readElement(this);
+    }
+    return array;
+  }
+
+  /**
+   * writeCond() writes the boolean condition @p cond to the buffer, then either calls if_true if
+   * @p cond is true, otherwise if_false
+   */
+  writeCond<T, F>(cond: boolean, fns: { if_true: () => T; if_false: () => F }) {
+    this.writeBool(cond);
+    if (cond) {
+      return fns.if_true();
+    } else {
+      return fns.if_false();
+    }
+  }
+
+  /**
+   * readCond() reads a boolean condition from the buffer, then either calls if_true if
+   * the condition was is true, otherwise if_false
+   */
+  readCond<T, F>(fns: { if_true: () => T; if_false: () => F }) {
+    if (this.readBool()) {
+      return fns.if_true();
+    } else {
+      return fns.if_false();
+    }
+  }
+
+  /**
+   * bumpWord() increments this.offset by @p bytes, after first aligning this.offset to @p bytes.
+   * @returns the old offset aligned to the next multiple of @p bytes, divided by @p bytes.
+   */
+  private bumpWord(bytes: number) {
+    const multiple = Math.floor((this.offset + bytes - 1) / bytes);
+    this.offset = (multiple + 1) * bytes;
+    return multiple;
+  }
+
+  private offset: number;
+  private u8: Uint8Array;
+  private u16: Uint16Array;
+  private u32: Uint32Array;
+  private i8: Int8Array;
+  private i16: Int16Array;
+  private i32: Int32Array;
+  private f16: Float16Array;
+  private f32: Float32Array;
+  private f64: Float64Array;
+}
diff --git a/src/webgpu/util/compare.ts b/src/webgpu/util/compare.ts
index 6fe7b34466d7..45599d25f63c 100644
--- a/src/webgpu/util/compare.ts
+++ b/src/webgpu/util/compare.ts
@@ -3,11 +3,11 @@ import { Colors } from '../../common/util/colors.js';
 import { assert, unreachable } from '../../common/util/util.js';
 import {
   deserializeExpectation,
-  SerializedExpectation,
   serializeExpectation,
 } from '../shader/execution/expression/case_cache.js';
 import { Expectation, toComparator } from '../shader/execution/expression/expression.js';
 
+import BinaryStream from './binary_stream.js';
 import { isFloatValue, Matrix, Scalar, Value, Vector } from './conversion.js';
 import { FPInterval } from './floating_point.js';
 
@@ -40,6 +40,40 @@ export interface Comparator {
   data?: Expectation | Expectation[] | string;
 }
 
+/** SerializedComparator is an enum of all the possible serialized comparator types. */
+enum SerializedComparatorKind {
+  AnyOf,
+  SkipUndefined,
+  AlwaysPass,
+}
+
+/** serializeComparatorKind() serializes a ComparatorKind to a BinaryStream */
+function serializeComparatorKind(s: BinaryStream, value: ComparatorKind) {
+  switch (value) {
+    case 'anyOf':
+      return s.writeU8(SerializedComparatorKind.AnyOf);
+    case 'skipUndefined':
+      return s.writeU8(SerializedComparatorKind.SkipUndefined);
+    case 'alwaysPass':
+      return s.writeU8(SerializedComparatorKind.AlwaysPass);
+  }
+}
+
+/** deserializeComparatorKind() deserializes a ComparatorKind from a BinaryStream */
+function deserializeComparatorKind(s: BinaryStream): ComparatorKind {
+  const kind = s.readU8();
+  switch (kind) {
+    case SerializedComparatorKind.AnyOf:
+      return 'anyOf';
+    case SerializedComparatorKind.SkipUndefined:
+      return 'skipUndefined';
+    case SerializedComparatorKind.AlwaysPass:
+      return 'alwaysPass';
+    default:
+      unreachable(`invalid serialized ComparatorKind: ${kind}`);
+  }
+}
+
 /**
  * compares 'got' Value  to 'expected' Value, returning the Comparison information.
  * @param got the Value obtained from the test
@@ -383,54 +417,27 @@ export function alwaysPass(msg: string = 'always pass'): Comparator {
   return c;
 }
 
-/** SerializedComparatorAnyOf is the serialized type of `anyOf` comparator. */
-type SerializedComparatorAnyOf = {
-  kind: 'anyOf';
-  data: SerializedExpectation[];
-};
-
-/** SerializedComparatorSkipUndefined is the serialized type of `skipUndefined` comparator. */
-type SerializedComparatorSkipUndefined = {
-  kind: 'skipUndefined';
-  data?: SerializedExpectation;
-};
-
-/** SerializedComparatorAlwaysPass is the serialized type of `alwaysPass` comparator. */
-type SerializedComparatorAlwaysPass = {
-  kind: 'alwaysPass';
-  reason: string;
-};
-
-// Serialized forms of 'value' and 'packed' are intentionally omitted, so should
-// not be put into the cache. Attempting to will cause a runtime assert.
-
-/** SerializedComparator is a union of all the possible serialized comparator types. */
-export type SerializedComparator =
-  | SerializedComparatorAnyOf
-  | SerializedComparatorSkipUndefined
-  | SerializedComparatorAlwaysPass;
-
-/**
- * Serializes a Comparator to a SerializedComparator.
- * @param c the Comparator
- * @returns a serialized comparator
- */
-export function serializeComparator(c: Comparator): SerializedComparator {
+/** serializeComparator() serializes a Comparator to a BinaryStream */
+export function serializeComparator(s: BinaryStream, c: Comparator) {
+  serializeComparatorKind(s, c.kind);
   switch (c.kind) {
-    case 'anyOf': {
-      const d = c.data as Expectation[];
-      return { kind: 'anyOf', data: d.map(serializeExpectation) };
-    }
-    case 'skipUndefined': {
-      if (c.data !== undefined) {
-        const d = c.data as Expectation;
-        return { kind: 'skipUndefined', data: serializeExpectation(d) };
-      }
-      return { kind: 'skipUndefined', data: undefined };
-    }
+    case 'anyOf':
+      s.writeArray(c.data as Expectation[], serializeExpectation);
+      return;
+    case 'skipUndefined':
+      s.writeCond(c.data !== undefined, {
+        if_true: () => {
+          // defined data
+          serializeExpectation(s, c.data as Expectation);
+        },
+        if_false: () => {
+          // undefined data
+        },
+      });
+      return;
     case 'alwaysPass': {
-      const d = c.data as string;
-      return { kind: 'alwaysPass', reason: d };
+      s.writeString(c.data as string);
+      return;
     }
     case 'value':
     case 'packed': {
@@ -441,22 +448,25 @@ export function serializeComparator(c: Comparator): SerializedComparator {
   unreachable(`Unable serialize comparator '${c}'`);
 }
 
-/**
- * Deserializes a Comparator from a SerializedComparator.
- * @param s the SerializedComparator
- * @returns the deserialized comparator.
- */
-export function deserializeComparator(s: SerializedComparator): Comparator {
-  switch (s.kind) {
-    case 'anyOf': {
-      return anyOf(...s.data.map(e => deserializeExpectation(e)));
-    }
-    case 'skipUndefined': {
-      return skipUndefined(s.data !== undefined ? deserializeExpectation(s.data) : undefined);
-    }
-    case 'alwaysPass': {
-      return alwaysPass(s.reason);
-    }
+/** deserializeComparator() deserializes a Comparator from a BinaryStream */
+export function deserializeComparator(s: BinaryStream): Comparator {
+  const kind = deserializeComparatorKind(s);
+  switch (kind) {
+    case 'anyOf':
+      return anyOf(...s.readArray(deserializeExpectation));
+    case 'skipUndefined':
+      return s.readCond({
+        if_true: () => {
+          // defined data
+          return skipUndefined(deserializeExpectation(s));
+        },
+        if_false: () => {
+          // undefined data
+          return skipUndefined(undefined);
+        },
+      });
+    case 'alwaysPass':
+      return alwaysPass(s.readString());
   }
   unreachable(`Unable deserialize comparator '${s}'`);
 }
diff --git a/src/webgpu/util/conversion.ts b/src/webgpu/util/conversion.ts
index e78af9783288..28a6e78f9137 100644
--- a/src/webgpu/util/conversion.ts
+++ b/src/webgpu/util/conversion.ts
@@ -3,6 +3,7 @@ import { ROArrayArray } from '../../common/util/types.js';
 import { assert, objectEquals, TypedArrayBufferView, unreachable } from '../../common/util/util.js';
 import { Float16Array } from '../../external/petamoriken/float16/float16.js';
 
+import BinaryStream from './binary_stream.js';
 import { kBit } from './constants.js';
 import {
   cartesianProduct,
@@ -888,11 +889,11 @@ export class Scalar {
   }
 
   /**
-   * Copies the scalar value to the Uint8Array buffer at the provided byte offset.
+   * Copies the scalar value to the buffer at the provided byte offset.
    * @param buffer the destination buffer
-   * @param offset the byte offset within buffer
+   * @param offset the offset in buffer, in units of @p buffer
    */
-  public copyTo(buffer: Uint8Array, offset: number) {
+  public copyTo(buffer: TypedArrayBufferView, offset: number) {
     assert(this.type.kind !== 'f64', `Copying f64 values to/from buffers is not defined`);
     workingDataU32[1] = this.bits1;
     workingDataU32[0] = this.bits0;
@@ -1301,86 +1302,222 @@ export type SerializedValueMatrix = {
   value: ROArrayArray<number>;
 };
 
-export type SerializedValue = SerializedValueScalar | SerializedValueVector | SerializedValueMatrix;
+enum SerializedScalarKind {
+  AbstractFloat,
+  F64,
+  F32,
+  F16,
+  U32,
+  U16,
+  U8,
+  I32,
+  I16,
+  I8,
+  Bool,
+}
 
-export function serializeValue(v: Value): SerializedValue {
-  const value = (kind: ScalarKind, s: Scalar) => {
+/** serializeScalarKind() serializes a ScalarKind to a BinaryStream */
+function serializeScalarKind(s: BinaryStream, v: ScalarKind) {
+  switch (v) {
+    case 'abstract-float':
+      s.writeU8(SerializedScalarKind.AbstractFloat);
+      return;
+    case 'f64':
+      s.writeU8(SerializedScalarKind.F64);
+      return;
+    case 'f32':
+      s.writeU8(SerializedScalarKind.F32);
+      return;
+    case 'f16':
+      s.writeU8(SerializedScalarKind.F16);
+      return;
+    case 'u32':
+      s.writeU8(SerializedScalarKind.U32);
+      return;
+    case 'u16':
+      s.writeU8(SerializedScalarKind.U16);
+      return;
+    case 'u8':
+      s.writeU8(SerializedScalarKind.U8);
+      return;
+    case 'i32':
+      s.writeU8(SerializedScalarKind.I32);
+      return;
+    case 'i16':
+      s.writeU8(SerializedScalarKind.I16);
+      return;
+    case 'i8':
+      s.writeU8(SerializedScalarKind.I8);
+      return;
+    case 'bool':
+      s.writeU8(SerializedScalarKind.Bool);
+      return;
+  }
+}
+
+/** deserializeScalarKind() deserializes a ScalarKind from a BinaryStream */
+function deserializeScalarKind(s: BinaryStream): ScalarKind {
+  const kind = s.readU8();
+  switch (kind) {
+    case SerializedScalarKind.AbstractFloat:
+      return 'abstract-float';
+    case SerializedScalarKind.F64:
+      return 'f64';
+    case SerializedScalarKind.F32:
+      return 'f32';
+    case SerializedScalarKind.F16:
+      return 'f16';
+    case SerializedScalarKind.U32:
+      return 'u32';
+    case SerializedScalarKind.U16:
+      return 'u16';
+    case SerializedScalarKind.U8:
+      return 'u8';
+    case SerializedScalarKind.I32:
+      return 'i32';
+    case SerializedScalarKind.I16:
+      return 'i16';
+    case SerializedScalarKind.I8:
+      return 'i8';
+    case SerializedScalarKind.Bool:
+      return 'bool';
+    default:
+      unreachable(`invalid serialized ScalarKind: ${kind}`);
+  }
+}
+
+enum SerializedValueKind {
+  Scalar,
+  Vector,
+  Matrix,
+}
+
+/** serializeValue() serializes a Value to a BinaryStream */
+export function serializeValue(s: BinaryStream, v: Value) {
+  const serializeScalar = (scalar: Scalar, kind: ScalarKind) => {
     switch (kind) {
+      case 'abstract-float':
+        s.writeF64(scalar.value as number);
+        return;
+      case 'f64':
+        s.writeF64(scalar.value as number);
+        return;
       case 'f32':
-        return s.bits0;
+        s.writeF32(scalar.value as number);
+        return;
       case 'f16':
-        return s.bits0;
-      default:
-        return s.value;
+        s.writeF16(scalar.value as number);
+        return;
+      case 'u32':
+        s.writeU32(scalar.value as number);
+        return;
+      case 'u16':
+        s.writeU16(scalar.value as number);
+        return;
+      case 'u8':
+        s.writeU8(scalar.value as number);
+        return;
+      case 'i32':
+        s.writeI32(scalar.value as number);
+        return;
+      case 'i16':
+        s.writeI16(scalar.value as number);
+        return;
+      case 'i8':
+        s.writeI8(scalar.value as number);
+        return;
+      case 'bool':
+        s.writeBool(scalar.value as boolean);
+        return;
     }
   };
+
   if (v instanceof Scalar) {
-    const kind = v.type.kind;
-    return {
-      kind: 'scalar',
-      type: kind,
-      value: value(kind, v),
-    };
+    s.writeU8(SerializedValueKind.Scalar);
+    serializeScalarKind(s, v.type.kind);
+    serializeScalar(v, v.type.kind);
+    return;
   }
   if (v instanceof Vector) {
-    const kind = v.type.elementType.kind;
-    return {
-      kind: 'vector',
-      type: kind,
-      value: v.elements.map(e => value(kind, e)) as boolean[] | readonly number[],
-    };
+    s.writeU8(SerializedValueKind.Vector);
+    serializeScalarKind(s, v.type.elementType.kind);
+    s.writeU8(v.type.width);
+    for (const element of v.elements) {
+      serializeScalar(element, v.type.elementType.kind);
+    }
+    return;
   }
   if (v instanceof Matrix) {
-    const kind = v.type.elementType.kind;
-    return {
-      kind: 'matrix',
-      type: kind,
-      value: v.elements.map(c => c.map(r => value(kind, r))) as ROArrayArray<number>,
-    };
+    s.writeU8(SerializedValueKind.Matrix);
+    serializeScalarKind(s, v.type.elementType.kind);
+    s.writeU8(v.type.cols);
+    s.writeU8(v.type.rows);
+    for (const column of v.elements) {
+      for (const element of column) {
+        serializeScalar(element, v.type.elementType.kind);
+      }
+    }
+    return;
   }
 
   unreachable(`unhandled value type: ${v}`);
 }
 
-export function deserializeValue(data: SerializedValue): Value {
-  const buildScalar = (v: ScalarValue): Scalar => {
-    switch (data.type) {
+/** deserializeValue() deserializes a Value from a BinaryStream */
+export function deserializeValue(s: BinaryStream): Value {
+  const deserializeScalar = (kind: ScalarKind) => {
+    switch (kind) {
       case 'abstract-float':
-        return abstractFloat(v as number);
+        return abstractFloat(s.readF64());
       case 'f64':
-        return f64(v as number);
-      case 'i32':
-        return i32(v as number);
-      case 'u32':
-        return u32(v as number);
+        return f64(s.readF64());
       case 'f32':
-        return f32Bits(v as number);
-      case 'i16':
-        return i16(v as number);
-      case 'u16':
-        return u16(v as number);
+        return f32(s.readF32());
       case 'f16':
-        return f16Bits(v as number);
-      case 'i8':
-        return i8(v as number);
+        return f16(s.readF16());
+      case 'u32':
+        return u32(s.readU32());
+      case 'u16':
+        return u16(s.readU16());
       case 'u8':
-        return u8(v as number);
+        return u8(s.readU8());
+      case 'i32':
+        return i32(s.readI32());
+      case 'i16':
+        return i16(s.readI16());
+      case 'i8':
+        return i8(s.readI8());
       case 'bool':
-        return bool(v as boolean);
-      default:
-        unreachable(`unhandled value type: ${data.type}`);
+        return bool(s.readBool());
     }
   };
-  switch (data.kind) {
-    case 'scalar': {
-      return buildScalar(data.value);
-    }
-    case 'vector': {
-      return new Vector(data.value.map(v => buildScalar(v)));
+  const valueKind = s.readU8();
+  const scalarKind = deserializeScalarKind(s);
+  switch (valueKind) {
+    case SerializedValueKind.Scalar:
+      return deserializeScalar(scalarKind);
+    case SerializedValueKind.Vector: {
+      const width = s.readU8();
+      const scalars = new Array<Scalar>(width);
+      for (let i = 0; i < width; i++) {
+        scalars[i] = deserializeScalar(scalarKind);
+      }
+      return new Vector(scalars);
     }
-    case 'matrix': {
-      return new Matrix(data.value.map(c => c.map(buildScalar)));
+    case SerializedValueKind.Matrix: {
+      const numCols = s.readU8();
+      const numRows = s.readU8();
+      const columns = new Array<Scalar[]>(numCols);
+      for (let c = 0; c < numCols; c++) {
+        columns[c] = new Array<Scalar>(numRows);
+        for (let i = 0; i < numRows; i++) {
+          columns[c][i] = deserializeScalar(scalarKind);
+        }
+      }
+      return new Matrix(columns);
     }
+    default:
+      unreachable(`invalid serialized value kind: ${valueKind}`);
   }
 }
 
diff --git a/src/webgpu/util/floating_point.ts b/src/webgpu/util/floating_point.ts
index 18a640f43403..b13f20fd6653 100644
--- a/src/webgpu/util/floating_point.ts
+++ b/src/webgpu/util/floating_point.ts
@@ -3,6 +3,7 @@ import { assert, unreachable } from '../../common/util/util.js';
 import { Float16Array } from '../../external/petamoriken/float16/float16.js';
 import { Case, IntervalFilter } from '../shader/execution/expression/expression.js';
 
+import BinaryStream from './binary_stream.js';
 import { anyOf } from './compare.js';
 import { kValue } from './constants.js';
 import {
@@ -40,18 +41,45 @@ import {
   unflatten2DArray,
   every2DArray,
 } from './math.js';
-import {
-  reinterpretF16AsU16,
-  reinterpretF32AsU32,
-  reinterpretF64AsU32s,
-  reinterpretU16AsF16,
-  reinterpretU32AsF32,
-  reinterpretU32sAsF64,
-} from './reinterpret.js';
 
 /** Indicate the kind of WGSL floating point numbers being operated on */
 export type FPKind = 'f32' | 'f16' | 'abstract';
 
+enum SerializedFPIntervalKind {
+  Abstract,
+  F32,
+  F16,
+}
+
+/** serializeFPKind() serializes a FPKind to a BinaryStream */
+export function serializeFPKind(s: BinaryStream, value: FPKind) {
+  switch (value) {
+    case 'abstract':
+      s.writeU8(SerializedFPIntervalKind.Abstract);
+      break;
+    case 'f16':
+      s.writeU8(SerializedFPIntervalKind.F16);
+      break;
+    case 'f32':
+      s.writeU8(SerializedFPIntervalKind.F32);
+      break;
+  }
+}
+
+/** deserializeFPKind() deserializes a FPKind from a BinaryStream */
+export function deserializeFPKind(s: BinaryStream): FPKind {
+  const kind = s.readU8();
+  switch (kind) {
+    case SerializedFPIntervalKind.Abstract:
+      return 'abstract';
+    case SerializedFPIntervalKind.F16:
+      return 'f16';
+    case SerializedFPIntervalKind.F32:
+      return 'f32';
+    default:
+      unreachable(`invalid deserialized FPKind: ${kind}`);
+  }
+}
 // Containers
 
 /**
@@ -138,81 +166,59 @@ export class FPInterval {
   }
 }
 
-/**
- * SerializedFPInterval holds the serialized form of a FPInterval.
- * This form can be safely encoded to JSON.
- */
-export type SerializedFPInterval =
-  | { kind: 'f32'; unbounded: false; begin: number; end: number }
-  | { kind: 'f32'; unbounded: true }
-  | { kind: 'f16'; unbounded: false; begin: number; end: number }
-  | { kind: 'f16'; unbounded: true }
-  | { kind: 'abstract'; unbounded: false; begin: [number, number]; end: [number, number] }
-  | { kind: 'abstract'; unbounded: true };
-
-/** serializeFPInterval() converts a FPInterval to a SerializedFPInterval */
-export function serializeFPInterval(i: FPInterval): SerializedFPInterval {
+/** serializeFPInterval() serializes a FPInterval to a BinaryStream */
+export function serializeFPInterval(s: BinaryStream, i: FPInterval) {
+  serializeFPKind(s, i.kind);
   const traits = FP[i.kind];
-  switch (i.kind) {
-    case 'abstract': {
-      if (i === traits.constants().unboundedInterval) {
-        return { kind: 'abstract', unbounded: true };
-      } else {
-        return {
-          kind: 'abstract',
-          unbounded: false,
-          begin: reinterpretF64AsU32s(i.begin),
-          end: reinterpretF64AsU32s(i.end),
-        };
-      }
-    }
-    case 'f32': {
-      if (i === traits.constants().unboundedInterval) {
-        return { kind: 'f32', unbounded: true };
-      } else {
-        return {
-          kind: 'f32',
-          unbounded: false,
-          begin: reinterpretF32AsU32(i.begin),
-          end: reinterpretF32AsU32(i.end),
-        };
+  s.writeCond(i !== traits.constants().unboundedInterval, {
+    if_true: () => {
+      // Bounded
+      switch (i.kind) {
+        case 'abstract':
+          s.writeF64(i.begin);
+          s.writeF64(i.end);
+          break;
+        case 'f32':
+          s.writeF32(i.begin);
+          s.writeF32(i.end);
+          break;
+        case 'f16':
+          s.writeF16(i.begin);
+          s.writeF16(i.end);
+          break;
+        default:
+          unreachable(`Unable to serialize FPInterval ${i}`);
+          break;
       }
-    }
-    case 'f16': {
-      if (i === traits.constants().unboundedInterval) {
-        return { kind: 'f16', unbounded: true };
-      } else {
-        return {
-          kind: 'f16',
-          unbounded: false,
-          begin: reinterpretF16AsU16(i.begin),
-          end: reinterpretF16AsU16(i.end),
-        };
-      }
-    }
-  }
-  unreachable(`Unable to serialize FPInterval ${i}`);
+    },
+    if_false: () => {
+      // Unbounded
+    },
+  });
 }
 
-/** serializeFPInterval() converts a SerializedFPInterval to a FPInterval */
-export function deserializeFPInterval(data: SerializedFPInterval): FPInterval {
-  const kind = data.kind;
+/** deserializeFPInterval() deserializes a FPInterval from a BinaryStream */
+export function deserializeFPInterval(s: BinaryStream): FPInterval {
+  const kind = deserializeFPKind(s);
   const traits = FP[kind];
-  if (data.unbounded) {
-    return traits.constants().unboundedInterval;
-  }
-  switch (kind) {
-    case 'abstract': {
-      return traits.toInterval([reinterpretU32sAsF64(data.begin), reinterpretU32sAsF64(data.end)]);
-    }
-    case 'f32': {
-      return traits.toInterval([reinterpretU32AsF32(data.begin), reinterpretU32AsF32(data.end)]);
-    }
-    case 'f16': {
-      return traits.toInterval([reinterpretU16AsF16(data.begin), reinterpretU16AsF16(data.end)]);
-    }
-  }
-  unreachable(`Unable to deserialize data ${data}`);
+  return s.readCond({
+    if_true: () => {
+      // Bounded
+      switch (kind) {
+        case 'abstract':
+          return traits.toInterval([s.readF64(), s.readF64()]);
+        case 'f32':
+          return traits.toInterval([s.readF32(), s.readF32()]);
+        case 'f16':
+          return traits.toInterval([s.readF16(), s.readF16()]);
+      }
+      unreachable(`Unable to deserialize FPInterval with kind ${kind}`);
+    },
+    if_false: () => {
+      // Unbounded
+      return traits.constants().unboundedInterval;
+    },
+  });
 }
 
 /**