@encharm/cws 4.11.0 → 4.11.2

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## Released 4.11.2
2
+ * microdeflate, output unchanged: match symbols write the bit accumulator unconditionally (their sizes vary, so the flush branch mispredicted), literals keep the predictable conditional flush; the 32 KB distance-code table is replaced by a computed code. Profiled with hardware counters on the production EPYC 9454P: 1.84 -> 1.72 µs/KB on the RPC capture (1.58 -> 1.48 on 24 KB messages), 1.13 -> 1.00 on Apple M; incompressible input unchanged. Tried and rejected with measurements: a packed 32 KB hash table (gcc emits partial-register masks on Zen), a single-branch candidate check (an unconditional random load costs more than the mispredicts it saves), `__restrict`, and smaller or larger tables.
3
+
4
+ ## Released 4.11.1
5
+ * microdeflate speed-ups, output unchanged: matches extend 8 bytes per step, the bit writer stores 8 bytes at a time, length/distance codes go out with their extra bits in one write, and each hash slot carries an 8-bit tag so a stale candidate is rejected without touching the input. Measured on the RPC capture: 1.74 -> 1.20 µs/KB (1.45x), byte-identical output. Incompressible input (random bytes) now falls back to stored blocks: 3x faster and 0.999 instead of 0.948 ratio.
6
+
1
7
  ## Released 4.11.0
2
8
  * Prepared messages for fan-out: `new PreparedMessage(bytes)` copies a payload into native memory once; `ws.send(prepared, { prefix })` sends `prefix + payload` as one frame without copying or compressing the payload per socket. Uncompressed, the payload goes out as a second gather buffer. Compressed (shared compressor mode), the prefix is emitted as a DEFLATE stored block ahead of the payload's deflate blocks, which are built once on the first compressed send and cached on the handle; standard inflaters accept the result unchanged. Sockets with context takeover and client sockets take the regular copying path. Measured with 300 subscribers and a 3 KB payload: 0.9 -> 0.2 µs of JS-thread time per socket, and no per-socket compression at all.
3
9
  * `send()` accepts any `ArrayBufferView` (typed arrays are sent from their own offset, no `Buffer.from` copy needed).
package/CLAUDE.md CHANGED
@@ -25,7 +25,7 @@ Tests bind ports 3000 (ws) and 3001 (wss, certs in `tests/certs/`). The test fil
25
25
 
26
26
  1. Download official Node header tarballs for one pinned version per supported major into `targets/` (`VER_115`=Node 20, `VER_127`=Node 22, `VER_137`=Node 24, `VER_147`=Node 26; the number is the Node ABI / `process.versions.modules`).
27
27
  2. Compile `src/*.cpp` once per ABI with `g++`/`cl` directly, with `-I src/headers/$V` for the matching Node major.
28
- 3. Build the vendored zlib-ng (`deps/zlib-ng`, native `zng_` API) once per OS/arch: CMake into `deps/zlib-ng/build-<OS>-<arch>/` on macOS/Linux, `nmake -f win32\Makefile.msc` on Windows. The bindings are compiled with `-DCWS_ZLIB_NG` and link it statically. `src/MicroDeflate.h` is a self-contained fixed-Huffman DEFLATE encoder used for independent messages (shared mode) via `zlib::deflateIndependent`; `src/Zlib.cpp` is the only file that includes a zlib header; without the define (node-gyp fallback) it uses Node's zlib.
28
+ 3. Build the vendored zlib-ng (`deps/zlib-ng`, native `zng_` API) once per OS/arch: CMake into `deps/zlib-ng/build-<OS>-<arch>/` on macOS/Linux, `nmake -f win32\Makefile.msc` on Windows. The bindings are compiled with `-DCWS_ZLIB_NG` and link it statically. `src/MicroDeflate.h` is a self-contained fixed-Huffman DEFLATE encoder used for independent messages (shared mode) via `zlib::deflateIndependent` (greedy LZ77 over a tagged 13-bit hash table, 8-byte match extension, computed distance codes, stored-block fallback when the input does not compress; output must stay byte-identical across speed work, and speed claims need the production EPYC, not only Apple silicon: gcc/Zen and clang/M-series disagreed on every layout change in 4.11.2, only the hybrid bit-writer flush won on both); Measured and rejected on the RPC capture, so do not retry without new data: dynamic Huffman trees per block (+9.4% ratio at 2.4x the time on EPYC), two candidates per hash slot (+2.5% at 1.5x), 3-byte minimum matches (worse ratio), packed 32 KB table with 16-bit tags (gcc partial-register stalls on Zen), single-branch candidate check (unconditional random load), `__restrict`, 12/14-bit tables. libdeflate level 1 reaches 3.28 vs our 2.86 at 2.6x the time; `src/Zlib.cpp` is the only file that includes a zlib header; without the define (node-gyp fallback) it uses Node's zlib.
29
29
  4. Emit `dist/bindings/cws_<platform>_<arch>_node<ABI>.node`.
30
30
 
31
31
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@encharm/cws",
3
- "version": "4.11.0",
3
+ "version": "4.11.2",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "description": "cWS - fast C++ WebSocket implementation for Node.js",
@@ -15,11 +15,15 @@
15
15
  #include <cstdint>
16
16
  #include <cstring>
17
17
  #include <cstddef>
18
+ #ifdef _MSC_VER
19
+ #include <intrin.h>
20
+ #endif
18
21
 
19
22
  namespace cWS {
20
23
  namespace microdeflate {
21
24
 
22
- // Worst case output for `length` input bytes (all literals at 9 bits + block overhead).
25
+ // Worst case output for `length` input bytes (all literals at 9 bits + block overhead),
26
+ // plus slack for the 8-byte stores of the bit writer.
23
27
  inline size_t bound(size_t length) {
24
28
  return length + length / 8 + 32;
25
29
  }
@@ -27,6 +31,9 @@ inline size_t bound(size_t length) {
27
31
  class Encoder {
28
32
  static const int HASH_BITS = 13;
29
33
  uint32_t table[1 << HASH_BITS];
34
+ // 8 more hash bits per slot: a candidate whose tag differs cannot match, and rejecting it
35
+ // here saves the dependent random load into the input that dominated the literal path.
36
+ uint8_t tags[1 << HASH_BITS];
30
37
 
31
38
  static const uint16_t *lenBase() { static const uint16_t t[29] = {3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258}; return t; }
32
39
  static const uint8_t *lenExtra() { static const uint8_t t[29] = {0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0}; return t; }
@@ -36,7 +43,6 @@ class Encoder {
36
43
  struct Tables {
37
44
  uint16_t litCode[288]; uint8_t litBits[288];
38
45
  uint8_t lenCode[259];
39
- uint8_t distCode[32769];
40
46
  uint8_t distCodeRev[30];
41
47
  static uint32_t rev(uint32_t v, int n) { uint32_t r = 0; for (int i = 0; i < n; i++) { r = (r << 1) | (v & 1); v >>= 1; } return r; }
42
48
  Tables() {
@@ -49,21 +55,85 @@ class Encoder {
49
55
  litCode[i] = (uint16_t) rev(code, bits); litBits[i] = (uint8_t) bits;
50
56
  }
51
57
  for (int c = 0; c < 29; c++) for (int l = lenBase()[c]; l < (c < 28 ? lenBase()[c + 1] : 259); l++) lenCode[l] = (uint8_t) c;
52
- for (int c = 0; c < 30; c++) { for (int d = distBase()[c]; d < (c < 29 ? distBase()[c + 1] : 32769); d++) distCode[d] = (uint8_t) c; distCodeRev[c] = (uint8_t) rev(c, 5); }
58
+ for (int c = 0; c < 30; c++) distCodeRev[c] = (uint8_t) rev(c, 5);
53
59
  }
54
60
  };
55
61
  static const Tables &tables() { static const Tables t; return t; }
56
62
 
63
+ // 64-bit accumulator flushed 8 bytes at a time with one unaligned store (the output
64
+ // buffer has slack for the over-write, see bound()). Literals (8 or 9 bits) drain the
65
+ // accumulator conditionally: their flush pattern repeats every few symbols and predicts
66
+ // well, and an unconditional store per literal made incompressible input store-bound on
67
+ // Zen 4 (+58%). Match symbols vary in size, so their flush branch mispredicted; they
68
+ // store unconditionally instead (-7% on the RPC capture on Zen 4, -10% on Apple M).
57
69
  struct BitWriter {
58
70
  uint8_t *out; size_t pos = 0; uint64_t acc = 0; int n = 0;
59
- inline void put(uint32_t v, int bits) { acc |= (uint64_t) v << n; n += bits; while (n >= 8) { out[pos++] = (uint8_t) acc; acc >>= 8; n -= 8; } }
60
- inline void flushByte() { if (n) { out[pos++] = (uint8_t) acc; acc = 0; n = 0; } }
71
+ inline void put(uint32_t v, int bits) {
72
+ acc |= (uint64_t) v << n; n += bits;
73
+ memcpy(out + pos, &acc, 8); pos += n >> 3; acc >>= n & ~7; n &= 7;
74
+ }
75
+ inline void putLit(uint32_t v, int bits) {
76
+ acc |= (uint64_t) v << n; n += bits;
77
+ if (n >= 32) { memcpy(out + pos, &acc, 8); pos += n >> 3; acc >>= n & ~7; n &= 7; }
78
+ }
79
+ inline void flushByte() { while (n > 0) { out[pos++] = (uint8_t) acc; acc >>= 8; n -= 8; } acc = 0; n = 0; }
61
80
  };
62
81
 
63
- static inline uint32_t hash4(const uint8_t *p) { uint32_t v; memcpy(&v, p, 4); return (v * 2654435761u) >> (32 - HASH_BITS); }
82
+ static inline uint32_t load32(const uint8_t *p) { uint32_t v; memcpy(&v, p, 4); return v; }
83
+ static inline uint64_t load64(const uint8_t *p) { uint64_t v; memcpy(&v, p, 8); return v; }
84
+ static inline uint32_t hash32(const uint8_t *p) { return load32(p) * 2654435761u; }
85
+ static inline uint32_t slot(uint32_t h) { return h >> (32 - HASH_BITS); }
86
+ static inline uint8_t tag(uint32_t h) { return (uint8_t) (h >> (32 - HASH_BITS - 8)); }
87
+ // Distance code from the highest set bit of dist-1: codes 0-3 are exact, then two codes
88
+ // per power of two, the lower one chosen by the bit below the msb. Replaces a 32 KB table.
89
+ static inline int distCode(uint32_t dist) {
90
+ uint32_t d = dist - 1;
91
+ if (d < 4) return (int) d;
92
+ #ifdef _MSC_VER
93
+ unsigned long msb; _BitScanReverse(&msb, d);
94
+ #else
95
+ int msb = 31 - __builtin_clz(d);
96
+ #endif
97
+ return ((int) msb << 1) | (int) ((d >> (msb - 1)) & 1);
98
+ }
99
+ static inline int ctz64(uint64_t v) {
100
+ #ifdef _MSC_VER
101
+ unsigned long r; _BitScanForward64(&r, v); return (int) r;
102
+ #else
103
+ return __builtin_ctzll(v);
104
+ #endif
105
+ }
106
+
107
+ // Length of the common prefix of a and b, at most `max`: 8 bytes per step while
108
+ // both sides have 8 bytes left (little-endian: the first differing byte is the
109
+ // lowest set byte of the xor), then byte by byte.
110
+ static inline size_t matchLength(const uint8_t *a, const uint8_t *b, size_t max) {
111
+ size_t m = 0;
112
+ while (m + 8 <= max) {
113
+ uint64_t x = load64(a + m) ^ load64(b + m);
114
+ if (x) return m + (ctz64(x) >> 3);
115
+ m += 8;
116
+ }
117
+ while (m < max && a[m] == b[m]) m++;
118
+ return m;
119
+ }
64
120
 
65
121
  public:
66
- Encoder() { memset(table, 0xff, sizeof(table)); tables(); }
122
+ Encoder() { memset(table, 0xff, sizeof(table)); memset(tags, 0, sizeof(tags)); tables(); }
123
+
124
+ // Stored blocks: what an incompressible message costs, 5 bytes per 65535 plus the tail.
125
+ static size_t storeBlocks(const uint8_t *in, size_t length, uint8_t *out) {
126
+ size_t pos = 0;
127
+ for (size_t i = 0; i < length; ) {
128
+ size_t n = length - i > 65535 ? 65535 : length - i;
129
+ out[pos++] = 0; // BFINAL=0, BTYPE=00
130
+ out[pos++] = (uint8_t) n; out[pos++] = (uint8_t) (n >> 8);
131
+ out[pos++] = (uint8_t) ~n; out[pos++] = (uint8_t) (~n >> 8);
132
+ memcpy(out + pos, in + i, n); pos += n; i += n;
133
+ }
134
+ out[pos++] = 0; // empty stored block header, byte aligned; the 00 00 ff ff tail is implied
135
+ return pos;
136
+ }
67
137
 
68
138
  // `out` must have room for bound(length) bytes. Returns the compressed length
69
139
  // (without the 4-byte sync-flush tail).
@@ -74,30 +144,34 @@ public:
74
144
  size_t i = 0;
75
145
  const size_t limit = length >= 4 ? length - 4 : 0;
76
146
  while (i < limit) {
77
- uint32_t h = hash4(in + i);
78
- uint32_t cand = table[h];
79
- table[h] = (uint32_t) i;
80
- if (cand < i && i - cand <= 32768 && memcmp(in + cand, in + i, 4) == 0) {
147
+ uint32_t h = hash32(in + i), s = slot(h);
148
+ uint8_t g = tag(h);
149
+ uint32_t cand = table[s];
150
+ bool tagged = tags[s] == g;
151
+ table[s] = (uint32_t) i;
152
+ tags[s] = g;
153
+ if (tagged && cand < i && i - cand <= 32768 && load32(in + cand) == load32(in + i)) {
81
154
  size_t maxLen = length - i; if (maxLen > 258) maxLen = 258;
82
- size_t m = 4;
83
- while (m < maxLen && in[cand + m] == in[i + m]) m++;
155
+ size_t m = 4 + matchLength(in + cand + 4, in + i + 4, maxLen - 4);
84
156
  uint32_t dist = (uint32_t) (i - cand);
85
157
  int lc = t.lenCode[m];
86
- w.put(t.litCode[257 + lc], t.litBits[257 + lc]);
87
- if (lenExtra()[lc]) w.put((uint32_t) (m - lenBase()[lc]), lenExtra()[lc]);
88
- int dc = t.distCode[dist];
89
- w.put(t.distCodeRev[dc], 5);
90
- if (distExtra()[dc]) w.put(dist - distBase()[dc], distExtra()[dc]);
91
- if (i + 1 < limit) table[hash4(in + i + 1)] = (uint32_t) (i + 1);
158
+ // length code + its extra bits in one put (at most 8 + 5 bits), same for distance (5 + 13)
159
+ w.put(t.litCode[257 + lc] | ((uint32_t) (m - lenBase()[lc]) << t.litBits[257 + lc]), t.litBits[257 + lc] + lenExtra()[lc]);
160
+ int dc = distCode(dist);
161
+ w.put(t.distCodeRev[dc] | ((dist - distBase()[dc]) << 5), 5 + distExtra()[dc]);
162
+ if (i + 1 < limit) { uint32_t h1 = hash32(in + i + 1); table[slot(h1)] = (uint32_t) (i + 1); tags[slot(h1)] = tag(h1); }
92
163
  i += m;
93
164
  } else {
94
- w.put(t.litCode[in[i]], t.litBits[in[i]]);
165
+ w.putLit(t.litCode[in[i]], t.litBits[in[i]]);
95
166
  i++;
96
167
  }
97
168
  }
98
- for (; i < length; i++) w.put(t.litCode[in[i]], t.litBits[in[i]]);
99
- w.put(t.litCode[256], t.litBits[256]); // end of block
100
- w.put(0, 1); w.put(0, 2); w.flushByte(); // empty stored block: BFINAL=0, BTYPE=00, then byte-align
169
+ for (; i < length; i++) w.putLit(t.litCode[in[i]], t.litBits[in[i]]);
170
+ w.putLit(t.litCode[256], t.litBits[256]); // end of block
171
+ w.putLit(0, 1); w.putLit(0, 2); w.flushByte(); // empty stored block: BFINAL=0, BTYPE=00, then byte-align
172
+ if (w.pos > length + 5 * (length / 65535 + 1) + 1) {
173
+ return storeBlocks(in, length, out); // incompressible: stored blocks are smaller
174
+ }
101
175
  out[w.pos++] = 0; out[w.pos++] = 0; out[w.pos++] = 0xff; out[w.pos++] = 0xff;
102
176
  return w.pos - 4;
103
177
  }