Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 32 additions & 23 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,39 +59,41 @@ Go warm benchmarked: 212, Go cold benchmarked: 212
├─────────────────────────────────────────────────────────────────────────────┤
│ │ JavaScript │ Go │ Difference │
├────────────────────┼──────────────┼──────────────┼──────────────────────────┤
│ Per File (avg) │ 880.46µs │ 712.86µs │ Similar (~19%) │
│ Per File (median) │ 511.25µs │ 553.33µs │ Similar (~8.2%) │
│ All Files (total) │ 185.78ms │ 151.13ms │ Similar (~18.7%) │
│ Per File (avg) │ 799.19µs │ 505.55µs │ Go 1.6x faster │
│ Per File (median) │ 483.42µs │ 385.98µs │ Go 1.3x faster │
│ All Files (total) │ 168.63ms │ 107.18ms │ Go 1.6x faster │
└────────────────────┴──────────────┴──────────────┴──────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│ 🔥 WARM PERFORMANCE (after 5 warmup runs) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │ JavaScript │ Go │ Difference │
├────────────────────┼──────────────┼──────────────┼──────────────────────────┤
│ Per File (avg) │ 457.09µs │ 667.49µs │ Go 1.5x slower │
│ Per File (median) │ 288.08µs │ 497.13µs │ Go 1.7x slower │
│ All Files (total) │ 96.90ms │ 141.51ms │ Go 1.5x slower │
│ Per File (avg) │ 402.75µs │ 476.68µs │ Similar (~18.4%) │
│ Per File (median) │ 272.10µs │ 363.09µs │ Go 1.3x slower │
│ All Files (total) │ 85.38ms │ 101.06ms │ Similar (~18.4%) │
└────────────────────┴──────────────┴──────────────┴──────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│ MEMORY & ALLOCATIONS (Go only, averaged per file) │
├─────────────────────────────────────────────────────────────────────────────┤
│ Memory per file: 0.34 MB │
│ Allocations per file: 6,451 allocations │
│ Memory per file: 0.30 MB │
│ Allocations per file: 5,408 allocations │
└─────────────────────────────────────────────────────────────────────────────┘

🔥 WARM PERFORMANCE (primary comparison metric):
🐌 Go is 1.5x slower than JavaScript (warm)
⚖️ Warm performance is similar (within 20%)

🥶 COLD START PERFORMANCE:
⚖️ Cold-start performance is SIMILAR (within 20%)
🚀 Go is 1.6x faster than JavaScript (cold start)

📈 WARMUP EFFECT:
JavaScript: 48.1% faster after warmup
Go: 6.4% faster after warmup
JavaScript: 49.6% faster after warmup
Go: 5.7% faster after warmup
```

The sample above was measured on July 15, 2026 on an Apple M1 Max with Go 1.24.5 and Node.js 24.4.1. Benchmark results are machine-dependent; compare changes on the same idle system.

### Run Individual Benchmarks

```bash
Expand Down Expand Up @@ -124,14 +126,16 @@ pnpm bench:go:suite
| `pnpm bench:go` | Per-file warm benchmarks (with 5 warmup runs) |
| `pnpm bench:go:cold` | Per-file cold-start benchmarks (no warmup) |
| `pnpm bench:go:suite` | Suite mode: All files sequentially, 30x with warmup |
| `pnpm bench:go:suite:gc200` | Suite mode with a throughput-oriented Go GC target |
| `pnpm bench:go:public` | Main exported `Compile` API |

## Benchmark Methodologies

### Realistic Suite Mode (Recommended for Production Decisions)
**Simulates actual CLI/build tool usage**
- **What it does**: Runs 30 independent processes, each compiling all files once
- **JavaScript**: 30 separate `node` processes
- **Go**: 30 independent benchmark iterations (fresh factory each time)
- **Go**: 30 separate executions of a precompiled Go benchmark binary
- **Use case**: Measuring real-world build tool performance
- **Process behavior**: Each iteration = fresh process start → compile all 212 benchmarked files → exit
- **No warmup**: Each build is independent, like real CLI usage
Expand All @@ -147,6 +151,18 @@ Build 30: Start process → [file1, file2, ..., file212] → Exit

**Why this matters**: Real-world CLI tools don't benefit from JIT warmup or in-process caching. Each build starts fresh.

### Go GC Throughput Tuning

The suite allocates about 65 MB while compiling 212 files, but most objects die quickly. On the measured system, Go's default `GOGC=100` ran roughly 553 GC cycles during a 15-build benchmark. Increasing the heap-growth target produced the following same-process results:

| Setting | Median suite time | GC cycles | Maximum observed heap goal |
|---------|------------------:|----------:|---------------------------:|
| `GOGC=100` | 112.07ms | 553 | 10 MB |
| `GOGC=200` | 96.78ms | 193 | 15 MB |
| `GOGC=400` | 91.34ms | 76 | 27 MB |

The warmed Node.js suite median was 102.57ms in the same session. `GOGC=200` is therefore the recommended starting point for throughput-oriented services. This is a process-wide Go runtime setting, so less.go deliberately does not change it inside the library. Re-measure under the application's actual memory limit and concurrency.

### Per-File Mode (Good for Optimization Work)
**Measures JIT optimization potential**
- **What it does**: Compiles each file 30 times individually (5 warmup + 25 measured)
Expand Down Expand Up @@ -316,9 +332,8 @@ The comparison script (`pnpm bench:compare`) runs both warm and cold-start bench
- JavaScript's JIT needs warmup to reach peak performance, Go doesn't
- Both metrics matter: warm for long-running processes, cold for CLI tools

**Q: Why is Go currently 2.1x slower (warm)?**
**A: Primarily allocations (~10,300 per file) and reflection usage.** The port has been significantly optimized but still has room for improvement. Recent optimizations have reduced allocations by ~78% and improved speed by ~4x. See detailed analysis:
- 📄 [`.claude/benchmarks/PERFORMANCE_ANALYSIS.md`](./.claude/benchmarks/PERFORMANCE_ANALYSIS.md)
**Q: Why does warmed V8 still win the isolated per-file benchmark?**
**A: Allocation and garbage-collection work now dominate the remaining gap.** The current suite allocates about 5,400 objects per file. The aggregate same-process gap is smaller than the isolated-file gap, and a measured `GOGC=200` setting closes it when the application can accept a modestly larger heap target.

**Q: How can I find the bottlenecks?**
**A: Use profiling:**
Expand All @@ -329,13 +344,7 @@ pnpm bench:profile
This will show CPU hot spots, memory allocations, and allocation hotspots.

**Q: Is this performance acceptable?**
**A: Yes, and improving rapidly!** Recent optimizations (#229-#233) have achieved:
- ✅ **78% reduction** in memory allocations (47k → 10.3k per file)
- ✅ **4x performance improvement** (8.1x slower → 2.1x slower warm)
- ✅ **Cold-start parity** with JavaScript (actually slightly faster!)
- ✅ **80+ tests passing** with identical CSS output

With continued targeted optimization, Go can match or exceed JavaScript warm performance while maintaining its cold-start advantage.
**A: Yes.** The fresh-process build benchmark is 1.8x faster than Less.js, cold per-file compilation is 1.6x faster, and the peak-JIT isolated-file gap is down to 18%. For long-lived throughput-oriented processes, `GOGC=200` moved the aggregate Go suite slightly ahead of the warmed Node suite in local testing.

## Contributing

Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,23 +121,27 @@ result, err := less.Compile(source, &less.CompileOptions{

## Performance

less.go provides native binary performance without requiring a JavaScript runtime. Current benchmarks cover 212 integration files and use Less.js v4.4.2 as the comparison point:
less.go provides native binary performance without requiring a JavaScript runtime. Current benchmarks cover 212 integration files and use Less.js v4.4.2 as the comparison point. These results were measured on an Apple M1 Max with Go 1.24.5 and Node.js 24.4.1:

| Metric | Less.js | less.go | Difference |
|--------|---------|---------|------------|
| Cold per-file average | 880.46µs | 712.86µs | Similar, Go ~19% faster |
| Warm per-file average | 457.09µs | 667.49µs | Go ~1.46x slower |
| Warm all-files total | 96.90ms | 141.51ms | Go ~1.46x slower |
| Memory per file | - | 0.34 MB | ~6,451 allocations |
| Fresh-process full build | 207.08ms | 117.46ms | Go 1.8x faster |
| Cold per-file average | 799.19µs | 505.55µs | Go 1.6x faster |
| Warm per-file average | 402.75µs | 476.68µs | Go 1.18x slower |
| Memory per file | - | 0.30 MB | ~5,408 allocations |

- **Cold-start remains competitive** - No JavaScript runtime required for core functionality
- **Warm Less.js JIT is still faster** - `pnpm bench:compare` is the parity gate for optimizer work
- **Native binary** - No JavaScript runtime needed for core functionality
- **CLI/build workloads are faster in Go** - The fresh-process 212-file workload is 1.8x faster than Less.js
- **Peak-JIT microbenchmarks are close** - V8 retains an 18% lead when each file is compiled repeatedly in isolation
- **GC tuning closes the aggregate warm gap** - `GOGC=200` reduced the same-process Go suite from about 112ms to 97ms in local testing, slightly ahead of the 103ms warmed Node suite, at the cost of a larger heap target

`GOGC` is process-wide, so the library does not change it automatically. Throughput-oriented services can start with `GOGC=200`, measure their own memory envelope, and consider `GOGC=400` only when latency matters more than memory.

Run benchmarks yourself:
```bash
pnpm bench:compare # Warm/cold per-file comparison across 212 files
pnpm bench:compare:suite # Realistic full-suite comparison
pnpm bench:go:public # Main exported Compile API
pnpm bench:go:suite:gc200 # Suite with the throughput-oriented GC setting
```

## Features
Expand Down
34 changes: 17 additions & 17 deletions less/anonymous.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,27 @@ import (

type Anonymous struct {
*Node
Value any
Index int
FileInfo map[string]any
MapLines bool
nodeStorage Node
Value any
Index int
FileInfo map[string]any
MapLines bool
RulesetLike bool
AllowRoot bool
AllowRoot bool
}

func NewAnonymous(value any, index int, fileInfo map[string]any, mapLines bool, rulesetLike bool, visibilityInfo map[string]any) *Anonymous {
node := NewNode()
anon := &Anonymous{}
node := initEmbeddedNode(&anon.nodeStorage)
node.TypeIndex = GetTypeIndexForNodeType("Anonymous")

anon := &Anonymous{
Node: node,
Value: value,
Index: index,
FileInfo: fileInfo,
MapLines: mapLines,
RulesetLike: rulesetLike,
AllowRoot: true,
}
anon.Node = node
anon.Value = value
anon.Index = index
anon.FileInfo = fileInfo
anon.MapLines = mapLines
anon.RulesetLike = rulesetLike
anon.AllowRoot = true
if visibilityInfo != nil {
if blocks, ok := visibilityInfo["visibilityBlocks"].(int); ok {
anon.VisibilityBlocks = &blocks
Expand Down Expand Up @@ -75,7 +75,7 @@ func (a *Anonymous) Compare(other any) any {
return 0
}
}

return nil
}

Expand Down Expand Up @@ -185,4 +185,4 @@ func (a *Anonymous) CopyVisibilityInfo(info map[string]any) {
if visible, ok := info["nodeVisible"].(bool); ok {
a.NodeVisible = &visible
}
}
}
22 changes: 11 additions & 11 deletions less/combinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ var NoSpaceCombinators = map[string]bool{

type Combinator struct {
*Node
Value string
nodeStorage Node
Value string
EmptyOrWhitespace bool
}

func NewCombinator(value string) *Combinator {
c := &Combinator{
Node: NewNode(),
}
c := &Combinator{}
c.Node = initEmbeddedNode(&c.nodeStorage)

if value == " " {
c.Value = " "
Expand All @@ -35,7 +35,7 @@ func trimWhitespace(s string) string {
if s == "" {
return s
}

runes := []rune(s)
start := 0
end := len(runes) - 1
Expand All @@ -47,20 +47,20 @@ func trimWhitespace(s string) string {
for end >= start && isJSWhitespace(runes[end]) {
end--
}

if start > end {
return ""
}

return string(runes[start : end+1])
}

func isJSWhitespace(r rune) bool {
switch r {
case '\t', '\n', '\v', '\f', '\r', ' ', // ASCII whitespace
0x00A0, // NO-BREAK SPACE
0x1680, // OGHAM SPACE MARK
0x180E, // MONGOLIAN VOWEL SEPARATOR
0x00A0, // NO-BREAK SPACE
0x1680, // OGHAM SPACE MARK
0x180E, // MONGOLIAN VOWEL SEPARATOR
0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, // Various spaces
0x2028, // LINE SEPARATOR
0x2029, // PARAGRAPH SEPARATOR
Expand Down Expand Up @@ -96,4 +96,4 @@ func (c *Combinator) GenCSS(context any, output *CSSOutput) {
spaceOrEmpty = " "
}
output.Add(spaceOrEmpty+c.Value+spaceOrEmpty, nil, nil)
}
}
27 changes: 13 additions & 14 deletions less/declaration.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ import (

type Declaration struct {
*Node
name any
Value *Value
important string
merge any // Can be bool or string ('+' for comma merge)
inline bool
variable bool
nodeStorage Node
name any
Value *Value
important string
merge any // Can be bool or string ('+' for comma merge)
inline bool
variable bool
}

// Uses sync.Pool to reuse Declaration objects.
func NewDeclaration(name any, value any, important any, merge any, index int, fileInfo map[string]any, inline bool, variable any) (*Declaration, error) {
node := NewNode()
d := &Declaration{}
node := initEmbeddedNode(&d.nodeStorage)
node.TypeIndex = GetTypeIndexForNodeType("Declaration")

d := GetDeclarationFromPool()
d.Node = node
// Intern property names when they are strings (most common case)
if nameStr, ok := name.(string); ok {
Expand Down Expand Up @@ -63,7 +63,7 @@ func NewDeclaration(name any, value any, important any, merge any, index int, fi
// Has GetType method, likely a node
isNode = true
}

if isNode {
// Value is already a Node, wrap it in Value([node])
newValue, err := NewValue([]any{value})
Expand Down Expand Up @@ -451,15 +451,15 @@ func (d *Declaration) GenCSS(context any, output *CSSOutput) {
default:
errMsg = fmt.Sprintf("%v", e)
}

// Create an error with index and filename similar to JavaScript
filename := ""
if d.FileInfo() != nil {
if f, ok := d.FileInfo()["filename"].(string); ok {
filename = f
}
}

// Re-panic with enhanced error message
panic(fmt.Errorf("%s (index: %d, filename: %s)", errMsg, d.GetIndex(), filename))
}
Expand Down Expand Up @@ -497,7 +497,6 @@ func (d *Declaration) GenCSS(context any, output *CSSOutput) {
}
}


func isLastRule(context any) bool {
if ctx, ok := context.(map[string]any); ok {
if lastRule, ok := ctx["lastRule"].(bool); ok {
Expand Down Expand Up @@ -691,4 +690,4 @@ func (d *Declaration) valueToCSS(context any) string {
// GenCSSSourceMap implements the SourceMapNode interface
func (d *Declaration) GenCSSSourceMap(context map[string]any, output *SourceMapOutput) {
d.genCSSSourceMapImpl(context, output)
}
}
Loading
Loading