내 CSV 정렬은 노드보다 전자에서 30배 느렸으며 문자열 해싱이었습니다.

작성자

카테고리:

← 피드로
DEV Community · Vivek Sthul · 2026-10-01 개발(SW)

I was building a desktop viewer for JSON, CSV and Markdown files, and I wanted it to handle a genuinely large CSV: a million rows, 55 MB, the kind of export an eval run spits out.

Loading was fine. Scrolling was fine. Then I clicked a column header to sort by a text column, and the window froze for over a minute.

The strange part: I had already tested that exact sorting code in a Node script against the same file, and it took about half a second.

Same code. Same data. Same machine. 140x apart.

First, rule out the obvious

My first three guesses were all wrong, so they’re worth listing:

  • It’s the DOM. It wasn’t — the table is virtualised, only about 48 rows exist at any time, and the freeze happened before anything was re-rendered.
  • It’s the comparator. My original sort called localeCompare with options on every comparison, which is genuinely slow. I replaced it with a shared Intl.Collator. It got better. It did not get 140x better.
  • It’s the sheer size. A million of anything is a lot, but Node had just done the same million in half a second.

So I profiled the renderer properly, and one line owned the flame graph:

counts.set(cellValue, (counts.get(cellValue) ?? 0) + 1);

Enter fullscreen mode Exit fullscreen mode

A Map.set keyed by a string. Nothing exotic.

The measurement that made it click

I pulled that loop out into a standalone test inside the app, hashing a million cell strings into a Map:

Time First pass over the cells 2.4 s Second pass over the same cells 80 ms

Thirty times faster the second time, with nothing cached in between — the Map was new each pass.

Two more data points narrowed it down:

  • Plain work over those same strings — length, charCodeAt, trim(), Number() — ran at normal speed on the first pass. So the strings weren’t slow to touch.
  • The effect only showed up once a large file was already loaded in that renderer. In a fresh renderer with a small file, the first pass was fine.

What’s different about the first pass is that V8 computes a string’s hash lazily, the first time the string is used as a key, and caches it in the string itself. Every cell coming out of the CSV parser is a fresh string — a slice of the file buffer that has never been used as a key. So a million first-time hashes, in a renderer with a large heap, was the entire cost.

I’ll be honest about the limit of my understanding here: I know what I measured and that avoiding it fixed the problem. I do not have a confident explanation for why the same operation is so much cheaper in a Node process, or exactly which part of the renderer’s state makes first-touch hashing expensive. If you know the V8 internals, I’d genuinely like to hear it.

The fix: hash it yourself

If V8’s first-touch hashing is the cost, stop making V8 hash the strings. Compute a small integer yourself, key the Map by that, and only compare strings when two of them collide:

class StringTable {
  constructor() {
    this.buckets = new Map();
    this.list = [];
  }

  add(value) {
    let h = 0x811c9dc5;                                   // FNV-1a
    for (let k = 0; k < value.length; k++) {
      h = Math.imul(h ^ value.charCodeAt(k), 16777619);
    }
    h &= 0x3fffffff;                                      // stays a small integer key

    const bucket = this.buckets.get(h);
    if (bucket !== undefined) {
      if (!Array.isArray(bucket)) {
        if (bucket.value === value) { bucket.count++; return bucket; }
      } else {
        for (const e of bucket) if (e.value === value) { e.count++; return e; }
      }
    }

    const entry = { value, count: 1, id: this.list.length };
    this.list.push(entry);
    if (bucket === undefined) this.buckets.set(h, entry);
    else if (Array.isArray(bucket)) bucket.push(entry);
    else this.buckets.set(h, [bucket, entry]);
    return entry;
  }
}

Enter fullscreen mode Exit fullscreen mode

It looks like the kind of thing you should never write in JavaScript — reimplementing a hash map on top of a hash map. A Map keyed by int32 skips the string hashing entirely, and charCodeAt in a tight loop is fast. The string comparison only runs on a collision, which is rare.

While I was there: stop comparing strings at all

Having distinct values in hand opens up a much better sort. Instead of comparing cells, reduce each column to one number per row, then sort the numbers.

For a text column, that number is the value’s rank in collation order — and you only have to collate the distinct values, which for real data is usually a tiny fraction of the rows:

export function sortKeys(rows, c, type) {
  const keys = new Float64Array(rows.length);
  let text = null;
  for (let i = 0; i < rows.length; i++) {
    const v = rows[i][c] ?? '';
    if (v.trim() === '') keys[i] = NaN;
    else if (type === 'number') keys[i] = toNumber(v) ?? 0;
    else if (type === 'date') keys[i] = parseDate(v) || 0;
    else keys[i] = (text ??= new StringTable()).add(v).id;
  }
  if (text) {
    const distinct = text.list.slice()
      .sort((a, b) => collator.compare(a.value, b.value));
    const rankById = new Float64Array(distinct.length);
    let rank = 0;
    for (let k = 0; k < distinct.length; k++) {
      if (k && collator.compare(distinct[k - 1].value, distinct[k].value) !== 0) rank++;
      rankById[distinct[k].id] = rank;
    }
    for (let i = 0; i < rows.length; i++) {
      if (keys[i] === keys[i]) keys[i] = rankById[keys[i]];
    }
  }
  return keys;
}

Enter fullscreen mode Exit fullscreen mode

Three things fall out of this:

  1. One Float64Array of keys per column, cached. Sort the same column again and there’s no parsing at all.
  2. No per-comparison callbacks. With integer ranks, a stable counting sort places every row in one pass over the data — no comparator function called twenty million times.
  3. NaN is a free “empty” marker, since v === v is false for NaN and nothing else.

The second thing I nearly missed

Column statistics (min, max, mean, top values) were still slower than they should have been, and this one had nothing to do with strings.

The code walked the rows in the order the current view had them. After a sort, that order is effectively random access across a 55 MB buffer — every read a cache miss.

Reading the rows in file order instead, and only then mapping back, was worth most of the remaining time. Sorted output, sequential input.

Where it ended up

On a Ryzen 5 7530U laptop, 55 MB CSV, one million rows:

Operation First implementation After Sort by a text column 70.7 s 248 ms Sort by a number column 4.29 s 398 ms Column statistics 8.38 s 347 ms

The collator fix was worth a lot. The hashing fix was worth most of the rest.

What to take from this

  • Profile in the environment you ship. “It’s fast in Node” told me nothing about a Chromium renderer with 55 MB of strings in it. If I’d trusted the Node benchmark I’d have shipped a minute-long freeze.
  • Run the hot loop twice. A second pass being dramatically faster is a strong signal that you’re paying a one-time per-value cost — lazy hashing, lazy interning, a megamorphic site warming up.
  • Fresh strings are not free. Anything that slices a large buffer — a CSV parser, a log tokeniser, a protocol decoder — hands you strings that have never been used as keys. If you then use millions of them as Map or object keys, that’s a cost you can measure and avoid.
  • Sort numbers, not values. Reducing a column to one numeric key per row and sorting that is faster than any comparator, and it caches well.

This came out of building Braceview, a viewer for JSON, CSV and Markdown files. It’s free, and you can drop a file on the homepage to try it without installing anything. Full method and machine details are in BENCHMARKS.md.

원문에서 계속 ↗