From 0c991f09ed94940132e035cc2aeb95e231fae5a6 Mon Sep 17 00:00:00 2001 From: Nevkontakte Date: Mon, 3 Aug 2020 21:13:31 +0100 Subject: [PATCH 1/2] Add support for analyzing GopherJS artifact size. When --analyze_size flag is passed to GopherJS, an additional ${packagename}.js.svg file is creates with a TreeMap diagram that visualizes which packages contributed to the artifact size and how much. The data is obtained by counting bytes written for each section of the file including GopherJS's own prelude and works accurately both in minified and default modes. Most of diagram-related logic is isolated to the build/analysis package with minimal changes to the rest of the code base. --- build/analysis/rect.go | 283 ++++++++++++++++++++++++++++++++++++ build/analysis/rect_test.go | 111 ++++++++++++++ build/analysis/tree.go | 245 +++++++++++++++++++++++++++++++ build/analysis/tree_test.go | 72 +++++++++ build/analysis/visualize.go | 75 ++++++++++ build/build.go | 20 +++ compiler/compiler.go | 39 +++++ go.mod | 3 +- go.sum | 2 + tool.go | 1 + 10 files changed, 850 insertions(+), 1 deletion(-) create mode 100644 build/analysis/rect.go create mode 100644 build/analysis/rect_test.go create mode 100644 build/analysis/tree.go create mode 100644 build/analysis/tree_test.go create mode 100644 build/analysis/visualize.go diff --git a/build/analysis/rect.go b/build/analysis/rect.go new file mode 100644 index 00000000..3d4a90eb --- /dev/null +++ b/build/analysis/rect.go @@ -0,0 +1,283 @@ +package analysis + +import ( + "encoding/xml" + "fmt" + "io" + "math" +) + +// rect represents a single rectangle area on the SVG rendering of the TreeMap diagram. +type rect struct { + left float64 + top float64 + right float64 + bottom float64 + transposed bool +} + +func newRect(w, h float64) rect { return rect{0, 0, w, h, false} } + +func (r rect) width() float64 { return r.right - r.left } + +func (r rect) height() float64 { return r.bottom - r.top } + +// long returns the length of the longer side of the rectangle. +func (r rect) long() float64 { + w, h := r.width(), r.height() + if w > h { + return w + } + return h +} + +// short returns the length of the shorter side of the rectangle. +func (r rect) short() float64 { + w, h := r.width(), r.height() + if w < h { + return w + } + return h +} + +// padding between the current rect's bounds and nested rectangles representing +// child nodes in the TreeMap. +func (r rect) padding() float64 { + const factor = 0.025 + padding := r.short() * factor + if padding > 10 { + padding = 10 + } + return padding +} + +// shrink the rectangle bounds by the given margin on every side. +// +// The center of the shrinked rectangle will be the same. Padding is +// automatically reduced to prevent shrinking to zero if necessary. +func (r rect) shrink(padding float64) rect { + if padding*2 > r.short()/2 { + padding = r.short() / 4 + } + shrinked := rect{ + left: r.left + padding, + top: r.top + padding, + right: r.right - padding, + bottom: r.bottom - padding, + transposed: r.transposed, + } + return shrinked +} + +// aspectRatio returns width divided by height. +func (r rect) aspectRatio() float64 { return r.width() / r.height() } + +// transpose flips X and Y coordinates of the rectangle and sets the +// "transposed" flag appropriately. +func (r rect) transpose() rect { + return rect{ + left: r.top, + top: r.left, + right: r.bottom, + bottom: r.right, + transposed: !r.transposed, + } +} + +// orientHorizontally transposes the rectangle such that the horizontal size is +// always the longer. +// +// When subdividing a rectangle in the diagram we always lay down stacks of +// children along the longer side for better readability, always orienting the +// rectangle horizontally prevents a lot of branching in the layout code. +// Calling restoreOrientation() will place the rectangle in the corrent place +// regardless of how many times it or its parents were transposed. +func (r rect) orientHorizontally() rect { + if r.aspectRatio() < 1 { + return r.transpose() + } + return r +} + +// restoreOrientation transposes the rectangle if necessary to return it to the +// original coordinate system. +func (r rect) restoreOrientation() rect { + if r.transposed { + return r.transpose() + } + return r +} + +// split the rectangle along the horizontal axis in protortion to the weights. +// transpose() the rectangle first in order to split along the vertical axis. +func (r rect) split(weights ...float64) rects { + var total float64 + for _, w := range weights { + total += w + } + + parts := []rect{} + var processedFraction float64 + for _, w := range weights { + fraction := w / total + parts = append(parts, rect{ + left: r.left + r.width()*processedFraction, + top: r.top, + right: r.left + r.width()*(processedFraction+fraction), + bottom: r.bottom, + transposed: r.transposed, + }) + processedFraction += fraction + } + + return parts +} + +// toSVG writes the SVG code to represent the current rect given the display options. +// +// Coordinates and sizes are rounded to 2 decimal digits to reduce chances of +// tests flaking out due to floating point imprecision. This will have no +// visible impact on the rendering since 0.01 unit == 0.01 pixel by default. +func (r rect) toSVG(w io.Writer, opts ...svgOpt) error { + normal := r.restoreOrientation() // Make sure we render the rect in its actual position. + + // Populate the default SVG representation of the rect for its coordinates. + data := svgGroup{ + Rect: &svgRect{ + X: round2(normal.left), + Y: round2(normal.top), + Width: round2(normal.width()), + Height: round2(normal.height()), + Fill: "black", + FillOpacity: 1, + Stroke: "black", + StrokeWidth: 0, + }, + Text: &svgText{ + X: round2(normal.left + normal.width()/2), + Y: round2(normal.top + normal.height()/2), + TextAnchor: "middle", + Baseline: "middle", + FontSize: 20, + }, + } + + // Apply all the display options passed by the caller. + for _, o := range opts { + o(&data) + } + + // Adjust rect label display such that it is readable and fits rectangle + // bounds. The constants below are empirically determined. + const ( + fontFactorX = 1.5 + fontFactorY = 0.8 + minFont = 8 + ) + if data.Text.FontSize > normal.short()*fontFactorY { + data.Text.FontSize = normal.short() * fontFactorY + } + if l := float64(len(data.Text.Text)); l*data.Text.FontSize > normal.long()*fontFactorX { + data.Text.FontSize = normal.long() / l * fontFactorX + } + if data.Text.FontSize < minFont { + data.Text.Text = "" + } + data.Text.FontSize = round2(data.Text.FontSize) + if normal.aspectRatio() < 1 { + data.Text.Transform = fmt.Sprintf("rotate(270 %0.2f %0.2f)", data.Text.X, data.Text.Y) + } + + if data.Text.Text == "" { + // Remove the text element if there's nothing to show. + data.Text = nil + } + if data.Rect.FillOpacity == 0 && data.Rect.StrokeWidth == 0 { + // Remove the rect element if it isn't supposed to be visible. + data.Rect = nil + } + + defer w.Write([]byte("\n")) + return xml.NewEncoder(w).Encode(data) +} + +type svgGroup struct { + XMLName struct{} `xml:"g"` + Rect *svgRect + Text *svgText `xml:",omitempty"` +} + +type svgRect struct { + XMLName struct{} `xml:"rect"` + X float64 `xml:"x,attr"` + Y float64 `xml:"y,attr"` + Width float64 `xml:"width,attr"` + Height float64 `xml:"height,attr"` + Fill string `xml:"fill,attr,omitempty"` + FillOpacity float64 `xml:"fill-opacity,attr"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth float64 `xml:"stroke-width,attr,omitempty"` + Title string `xml:"title,omitempty"` +} + +type svgText struct { + XMLName struct{} `xml:"text"` + Text string `xml:",chardata"` + X float64 `xml:"x,attr"` + Y float64 `xml:"y,attr"` + FontSize float64 `xml:"font-size,attr"` + Transform string `xml:"transform,attr,omitempty"` + TextAnchor string `xml:"text-anchor,attr,omitempty"` + Baseline string `xml:"dominant-baseline,attr,omitempty"` +} + +type svgOpt func(r *svgGroup) + +// WithTooltip adds a hover tooltip text to the rectangle. +func WithTooltip(t string) svgOpt { + return func(g *svgGroup) { g.Rect.Title = t } +} + +// WithText adds a text label over the rectangle. +func WithText(t string) svgOpt { + return func(g *svgGroup) { g.Text.Text = t } +} + +// WithFill sets rectangle fill style. +func WithFill(color string, opacity float64) svgOpt { + return func(g *svgGroup) { + g.Rect.Fill = color + g.Rect.FillOpacity = opacity + } +} + +// WithStroke sets rectangle outline stroke style. +func WithStroke(color string, width float64) svgOpt { + return func(g *svgGroup) { + g.Rect.Stroke = color + g.Rect.StrokeWidth = width + } +} + +// rects is a group of rectangles representing sibling nodes in the tree. +type rects []rect + +// maxAspect returns the highest aspect ratio amount the rect group. +// +// Aspect ratios lesser than 1 are inverted to be above 1. The closer the return +// value is to 1, the closer all rectangles in the group are to squares. +func (rr rects) maxAspect() float64 { + var result float64 = 1 // Start as if we have a perfectly square layout. + for _, r := range rr { + aspect := r.aspectRatio() + if aspect < 1 { + aspect = 1 / aspect + } + if aspect > result { + result = aspect + } + } + return result +} + +func round2(v float64) float64 { return math.Round(v*100) / 100 } diff --git a/build/analysis/rect_test.go b/build/analysis/rect_test.go new file mode 100644 index 00000000..5a5aa36d --- /dev/null +++ b/build/analysis/rect_test.go @@ -0,0 +1,111 @@ +package analysis + +import ( + "bytes" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestRectSplit(t *testing.T) { + tests := []struct { + name string + original rect + weights []float64 + parts rects + }{ + { + name: "single part", + original: rect{0, 1, 10, 2, true}, + weights: []float64{42}, + parts: rects{{0, 1, 10, 2, true}}, + }, + { + name: "two parts", + original: rect{0, 1, 10, 2, true}, + weights: []float64{4, 6}, + parts: rects{{0, 1, 4, 2, true}, {4, 1, 10, 2, true}}, + }, + { + name: "many parts", + original: rect{0, 1, 10, 2, true}, + weights: []float64{2, 4, 6, 8}, + parts: rects{{0, 1, 1, 2, true}, {1, 1, 3, 2, true}, {3, 1, 6, 2, true}, {6, 1, 10, 2, true}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.original.split(test.weights...) + if diff := cmp.Diff(test.parts, got, cmp.AllowUnexported(rect{}), cmpopts.EquateApprox(0.0001, 0.0001)); diff != "" { + t.Errorf("rect.split(%v) returned diff:\n%s", test.weights, diff) + } + }) + } +} + +func TestRectToSVG(t *testing.T) { + tests := []struct { + name string + rect rect + opts []svgOpt + want string + }{ + { + name: "default", + rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false}, + want: ``, + }, { + name: "transposed", + rect: rect{left: 2, top: 1, right: 4, bottom: 3, transposed: true}, + want: ``, + }, { + name: "red stroke", + rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false}, + opts: []svgOpt{WithStroke("#F00", 0.5)}, + want: ``, + }, { + name: "red fill", + rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false}, + opts: []svgOpt{WithFill("#F00", 0.5)}, + want: ``, + }, { + name: "with text", + rect: rect{left: 10, top: 10, right: 80, bottom: 40, transposed: false}, + opts: []svgOpt{WithText("Hello, world!")}, + want: `` + + `Hello, world!`, + }, { + name: "with text vertical", + rect: rect{left: 10, top: 10, right: 40, bottom: 80, transposed: false}, + opts: []svgOpt{WithText("Hello, world!")}, + want: `` + + `Hello, world!`, + }, { + name: "with text too small", + rect: rect{left: 1, top: 1, right: 8, bottom: 4, transposed: false}, + opts: []svgOpt{WithText("Hello, world!")}, + want: ``, + }, { + name: "with tooltip", + rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false}, + opts: []svgOpt{WithTooltip("Hello, world!")}, + want: `Hello, world!`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + b := &bytes.Buffer{} + if err := test.rect.toSVG(b, test.opts...); err != nil { + t.Fatalf("rect.toSVG() returned error: %s", err) + } + got := strings.TrimSpace(b.String()) + if diff := cmp.Diff(test.want, got); diff != "" { + t.Errorf("rect.toSVG() returned diff (-want,+got):\n%s", diff) + } + }) + } +} diff --git a/build/analysis/tree.go b/build/analysis/tree.go new file mode 100644 index 00000000..87e445fa --- /dev/null +++ b/build/analysis/tree.go @@ -0,0 +1,245 @@ +package analysis + +import ( + "crypto/md5" + "fmt" + "image/color" + "io" + "math" + "math/bits" + "sort" + "strings" + + "github.com/dustin/go-humanize" +) + +// treeMapNode represents a single node in a Tree Map diagram. +// +// Each node has a label and own size associated with it, as well as a list of +// direct descendants. +// +// Even though this type can be used for building Tree Map diagrams of anything +// (e.g. file system) it has extra features to handle dependency graph use case. +// Node's children must form a directed acyclic graph, not necessarily a tree. +// It will be later transformed into a tree by a organizeTree() call. +type treeMapNode struct { + Label string + Size float64 + Children nodeGroup + + // The following fields are populated from the tree structure by organizeTree() + depth int // Shortest distance between the node and the tree root. + parent *treeMapNode // Selected tree parent for the current node. + effectiveChildren nodeGroup // Nodes for which this node was selected as a parent sorted by cumulative size. + cumulativeSize float64 +} + +// organizeTree transforms the acyclic import graph into a spanning tree. +// +// In order to visualize import graph and package sizes as a Tree Map diagram we +// need to reduce it to a tree to make sure we don't double-count size +// contribution of transitive dependencies even if they are imported by multiple +// other packages. We solve this by selecting a single parent for every node of +// the import graph and rendering the node only under the selected parent. +// Empirically, the diagram is most readable if we selected the parent with the +// shortest distance from the tree root (main package). +func (root *treeMapNode) organizeTree() *treeMapNode { + // Mark all nodes as initially not visited. + var prepare func(node *treeMapNode) + prepare = func(node *treeMapNode) { + node.depth = 1<<(bits.UintSize-1) - 1 // Max int + for _, c := range node.Children { + prepare(c) + } + } + prepare(root) + root.depth = 0 + + // Use Dijkstra's algorithm to build a spanning tree. + queue := []*treeMapNode{root} + processNode := func(node *treeMapNode) { + // Sort children by name to ensure deterministic result. + sort.Slice(node.Children, func(i, j int) bool { + return node.Children[i].Label < node.Children[j].Label + }) + // Select provisional parents for all children. + for _, child := range node.Children { + if child.depth > node.depth+1 { + child.depth = node.depth + 1 + child.parent = node + } + queue = append(queue, child) + } + } + for len(queue) > 0 { + processNode(queue[0]) + queue = queue[1:] + } + + // Precompute some numbers we'll been to build the diagram. + var precompute func(node *treeMapNode) + precompute = func(node *treeMapNode) { + node.cumulativeSize = node.Size + for _, child := range node.Children { + if child.parent == node { + precompute(child) // Compute all the metrics for the child first. + node.effectiveChildren = append(node.effectiveChildren, child) + node.cumulativeSize += child.cumulativeSize + } + } + // Sort effective children by size descending, this tends to produce better-looking diagrams. + sort.Slice(node.effectiveChildren, func(i, j int) bool { + if node.effectiveChildren[i].cumulativeSize != node.effectiveChildren[j].cumulativeSize { + return node.effectiveChildren[i].cumulativeSize > node.effectiveChildren[j].cumulativeSize + } + return node.effectiveChildren[i].Label < node.effectiveChildren[j].Label // To guarantee determinism. + }) + } + precompute(root) + return root +} + +// color assigns a deterministic pseudo-random color to the node. Colors are in +// light pastel gamma to provide good readable contrast for text labels. +func (n *treeMapNode) color() string { + hash := md5.Sum([]byte(n.Label)) + cb := hash[0] + cr := hash[1] + y := uint8(256 * (0.25 + math.Pow(0.9, float64(n.depth))/2)) + r, g, b := color.YCbCrToRGB(y, cb, cr) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +// renderSelf renders a labeled rectangle for the node itself within the given +// bounding area and returns a sub-area where children nodes should be rendered. +// Bounding area is split between self and children based on the relative +// weights. +func (n *treeMapNode) renderSelf(w io.Writer, bounds rect) (rect, error) { + // Output own bounding rect into the SVG stream. + if err := bounds.toSVG(w, WithTooltip(n.Describe()), WithFill(n.color(), 0.1), WithStroke("black", 0.5)); err != nil { + return rect{}, fmt.Errorf("failed to render self to svg: %s", err) + } + + // Create a bit of a border for better visualization of the tree hierarchy. + bounds = bounds.shrink(bounds.padding()) + + // Reserve some space to represent own size. + ownSplit := bounds.split(n.Size, n.cumulativeSize-n.Size) + ownSplit[0].toSVG(w, WithFill("none", 0), WithText(fmt.Sprintf("%s — %s", n.Label, humanize.IBytes(uint64(n.Size))))) + + // The rest will be allocated to children. + bounds = ownSplit[1] + return bounds, nil +} + +// toSVG renders Tree Map diagram for the node and its children in the given bounding area. +// +// This function implements a simplified version of the Squarified Treemaps [1] +// algorithm to avoid generating a lot of thin and long nodes, which are +// difficult to read and label. +// +// Note that organizeTree() needs to be called prior to this method to ensure +// every element in the graph is rendered exactly once. +// +// [1]: Bruls, Mark, Kees Huizing, and Jarke J. Van Wijk. "Squarified treemaps." +// Data visualization 2000. Springer, Vienna, 2000. 33-42. +func (n *treeMapNode) toSVG(w io.Writer, bounds rect) error { + // Reserve a fraction of the bounding area to represent node's own weight. + bounds, err := n.renderSelf(w, bounds) + if err != nil { + return fmt.Errorf("failed to render self: %s", err) + } + + if len(n.effectiveChildren) == 0 { + return nil + } + + // Renders a stack of child nodes within the corresponding bounding areas. + renderStack := func(stack nodeGroup, bounds rects) error { + if len(stack) != len(bounds) { + // This should never happen ™. + panic(fmt.Sprintf("Tried to lay out node stack %v inside %d bounding rects, need %d", stack, len(bounds), len(stack))) + } + for i, node := range stack { + node.toSVG(w, bounds[i]) + } + return nil + } + + bounds = bounds.orientHorizontally() + stack := nodeGroup{n.effectiveChildren[0]} // Seed the first stack of children. + queue := n.effectiveChildren[1:] // Queue the remaining children. + split := bounds.split(stack.totalSize(), queue.totalSize()) // Split the available space between the stack and the remainder. + stackBounds := split[0].split(stack.cumulativeSizes()...) // Subdivide stack space for each stack element. + + // Attempt to arrange children into one or more "stacks" inside the bounding + // area such that each child's area is as square as possible. We do it + // greedily by adding children into stacks as long as it improves aspect + // ratios within the stack. + for len(queue) > 0 { + var candidate *treeMapNode + candidate, queue = queue[0], queue[1:] + newStack := append(stack, candidate) + newSplit := bounds.split(newStack.totalSize(), queue.totalSize()) + newStackBounds := newSplit[0].orientHorizontally().split(newStack.cumulativeSizes()...) + + if newStackBounds.maxAspect() > stackBounds.maxAspect() { + // The new stack made the layout less square, so we commit the the + // previous version of the stack and seed a new stack. + if err := renderStack(stack, stackBounds); err != nil { + return fmt.Errorf("failed to lay out children of %q: %s", n.Label, err) + } + + bounds = split[1] // Reduce the available layout space to that's left. + stack = nodeGroup{candidate} // Seed the new stack. + split = bounds.split(stack.totalSize(), queue.totalSize()) + stackBounds = split[0].split(stack.cumulativeSizes()...) + } else { + // The new stack looks better, accept it and keep adding to it. + stack = newStack + split = newSplit + stackBounds = newStackBounds + } + } + + // Render the last stack as it is. + if err := renderStack(stack, stackBounds); err != nil { + return fmt.Errorf("failed to lay out children of %q: %s", n.Label, err) + } + + return nil +} + +// Describe the node itself. +func (n *treeMapNode) Describe() string { + return fmt.Sprintf("%s — %s own size, %s with children", n.Label, humanize.IBytes(uint64(n.Size)), humanize.IBytes(uint64(n.cumulativeSize))) +} + +// String representation of the node and its effective children. +func (n *treeMapNode) String() string { + s := &strings.Builder{} + fmt.Fprintf(s, " %s %s\n", strings.Repeat(" *", n.depth), n.Describe()) + for _, c := range n.effectiveChildren { + s.WriteString(c.String()) + } + return s.String() +} + +// nodeGroup represents a group of adjacent tree nodes. +type nodeGroup []*treeMapNode + +func (ng nodeGroup) totalSize() float64 { + var size float64 + for _, c := range ng { + size += c.cumulativeSize + } + return size +} + +func (ng nodeGroup) cumulativeSizes() []float64 { + sizes := []float64{} + for _, c := range ng { + sizes = append(sizes, c.cumulativeSize) + } + return sizes +} diff --git a/build/analysis/tree_test.go b/build/analysis/tree_test.go new file mode 100644 index 00000000..834e04ca --- /dev/null +++ b/build/analysis/tree_test.go @@ -0,0 +1,72 @@ +package analysis + +import ( + "bytes" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestTreeNodeOrganize(t *testing.T) { + io := &treeMapNode{Label: "io", Size: 10 * 1024} + strconv := &treeMapNode{Label: "strconv", Size: 1 * 1024} + fmt := &treeMapNode{Label: "fmt", Size: 50 * 1024, Children: nodeGroup{io, strconv}} + main := &treeMapNode{Label: "main", Size: 1024 * 1024, Children: nodeGroup{io, fmt}} + + main.organizeTree() + + // Text representation of the spanning tree for the acyclic dependency graph. + // Note that "io" is assigned as effective child of "main", since this is a + // shorter path to the tree root. + want := "" + + " main — 1.0 MiB own size, 1.1 MiB with children\n" + + " * fmt — 50 KiB own size, 51 KiB with children\n" + + " * * strconv — 1.0 KiB own size, 1.0 KiB with children\n" + + " * io — 10 KiB own size, 10 KiB with children\n" + got := main.String() + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("main.organizeTree() produced unexpected structure (-want,+got):\n%s", diff) + } +} + +func TestTreeNodeToSVG(t *testing.T) { + // Build a dependency graph for a very simple project. + cpu := &treeMapNode{Label: "internal/cpu", Size: 342} + bytealg := &treeMapNode{Label: "internal/bytealg", Size: 468, Children: nodeGroup{cpu}} + sys := &treeMapNode{Label: "runtime/internal/sys", Size: 350} + js := &treeMapNode{Label: "gopherjs/js", Size: 5.2 * 1024} + runtime := &treeMapNode{Label: "runtime", Size: 3.8 * 1024, Children: nodeGroup{js, bytealg, sys}} + prelude := &treeMapNode{Label: "$prelude", Size: 32 * 1024} + main := &treeMapNode{Label: "main", Size: 434} + artifact := &treeMapNode{Label: "$artifact", Size: 0, Children: nodeGroup{prelude, runtime, main}} + + artifact.organizeTree() + b := &bytes.Buffer{} + if err := artifact.toSVG(b, newRect(1000, 1000)); err != nil { + t.Fatalf("artifact.toSVG() returned error: %s", err) + } + + want := `$artifact — 0 B own size, 43 KiB with children + +$prelude — 32 KiB own size, 32 KiB with children +$prelude — 32 KiB +runtime — 3.8 KiB own size, 10 KiB with children +runtime — 3.8 KiB +gopherjs/js — 5.2 KiB own size, 5.2 KiB with children +gopherjs/js — 5.2 KiB +internal/bytealg — 468 B own size, 810 B with children + +internal/cpu — 342 B own size, 342 B with children +internal/cpu — 342 B +runtime/internal/sys — 350 B own size, 350 B with children + +main — 434 B own size, 434 B with children + +` + got := b.String() + fmt.Println(got) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("artifact.toSVG() returned diff (-want,+got):\n%s", diff) + } +} diff --git a/build/analysis/visualize.go b/build/analysis/visualize.go new file mode 100644 index 00000000..1d8328dd --- /dev/null +++ b/build/analysis/visualize.go @@ -0,0 +1,75 @@ +package analysis + +import ( + "fmt" + "os" + + "github.com/goplusjs/gopherjs/compiler" +) + +// Visualizer renders a TreeMap diagram representing the generated JS file and +// how different packages contributed it its size. +type Visualizer struct { + File *os.File + Main *compiler.Archive + Deps []*compiler.Archive +} + +// Render a Tree Map diagram for the given package sizes. +// +// Stats contains amount of bytes written that correspond to a given package. +func (v *Visualizer) Render(stats map[string]int) error { + tree := v.buildTree(stats) + // This roughly corresponds to browser viewport dimensions on a 1080p screen. + const w = 1500 + const h = 700 + fmt.Fprintf(v.File, ` + + + +`, w, h, w, h) + if err := tree.toSVG(v.File, newRect(w, h)); err != nil { + return err + } + fmt.Fprintf(v.File, "\n") + return nil +} + +func (v *Visualizer) buildTree(stats map[string]int) *treeMapNode { + deps := map[string]*compiler.Archive{} + + for _, dep := range v.Deps { + deps[dep.ImportPath] = dep + } + + nodes := map[string]*treeMapNode{} // List of all nodes in the tree. + roots := map[string]*treeMapNode{} // Nodes that are not someone's dependency. + for name, size := range stats { + nodes[name] = &treeMapNode{ + Label: name, + Size: float64(size), + } + roots[name] = nodes[name] + } + + for name, node := range nodes { + if dep, ok := deps[name]; ok { + for _, depName := range dep.Imports { + node.Children = append(node.Children, nodes[depName]) + delete(roots, depName) + } + } + } + + topLevel := []*treeMapNode{} + for _, node := range roots { + topLevel = append(topLevel, node) + } + + return (&treeMapNode{ + Label: "$artifact", + Size: 0, // No own size, just children. + Children: topLevel, + depth: 0, + }).organizeTree() +} diff --git a/build/build.go b/build/build.go index 112e1314..8b036ea0 100644 --- a/build/build.go +++ b/build/build.go @@ -22,6 +22,7 @@ import ( "time" "github.com/fsnotify/fsnotify" + "github.com/goplusjs/gopherjs/build/analysis" "github.com/goplusjs/gopherjs/compiler" "github.com/goplusjs/gopherjs/compiler/gopherjspkg" "github.com/goplusjs/gopherjs/compiler/natives" @@ -480,6 +481,7 @@ type Options struct { Quiet bool Watch bool CreateMapFile bool + AnalyzeSize bool MapToLocalDisk bool Minify bool Color bool @@ -956,6 +958,24 @@ func (s *Session) WriteCommandPackage(archive *compiler.Archive, pkgObj string) if err != nil { return err } + + if s.options.AnalyzeSize { + f, err := os.Create(pkgObj + ".svg") + if err != nil { + return fmt.Errorf("failed to create artifact size diagram file: %s", err) + } + defer f.Close() + visualizer := &analysis.Visualizer{ + Main: archive, + Deps: deps, + File: f, + } + defer func() { + if err := visualizer.Render(sourceMapFilter.BytesWritten); err != nil { + fmt.Fprintf(os.Stderr, "Failed to render artifact size diagram: %s", err) + } + }() + } return compiler.WriteProgramCode(deps, sourceMapFilter) } diff --git a/compiler/compiler.go b/compiler/compiler.go index dc72bcd4..e4b59442 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -150,6 +150,9 @@ func WriteProgramCode(pkgs []*Archive, w *SourceMapFilter) error { } } + leaveSection := w.EnterSection("$prelude") + defer leaveSection() + if _, err := w.Write([]byte("\"use strict\";\n(function() {\n\n")); err != nil { return err } @@ -179,6 +182,9 @@ func WriteProgramCode(pkgs []*Archive, w *SourceMapFilter) error { } func WritePkgCode(pkg *Archive, dceSelection map[*Decl]struct{}, minify bool, w *SourceMapFilter) error { + leaveSection := w.EnterSection(pkg.ImportPath) + defer leaveSection() + if w.MappingCallback != nil && pkg.FileSet != nil { w.fileSet = token.NewFileSet() if err := w.fileSet.Read(json.NewDecoder(bytes.NewReader(pkg.FileSet)).Decode); err != nil { @@ -260,9 +266,30 @@ type SourceMapFilter struct { line int column int fileSet *token.FileSet + // BytesWritten per program section. + // + // This information is used to produce artifact size report. The key is + // a Go package name or one of a special IDs: $prelude. + BytesWritten map[string]int + // The current program section being written. Empty string means "UNKNOWN". + currentSection string } +// Write program code and populate source map if available. +// +// The passed byte slice is written verbatim, except when it contains an ASCII +// "backspace" character (\b, 0x08). In that case the 4 bytes following the +// backspace character are interpreted as big-endian-encoded token.Pos that must +// be present in the fileSet. The position is interpreted as the position in the +// original source code for the JS code that is about to be written (?) and will +// be passed to the MappingCallback in order to emit the appropriate source map. +// The sequence of \b and the following 4 bytes are then omitted from the actual +// output. func (f *SourceMapFilter) Write(p []byte) (n int, err error) { + if f.BytesWritten == nil { + f.BytesWritten = map[string]int{} + } + var n2 int for { i := bytes.IndexByte(p, '\b') @@ -273,6 +300,7 @@ func (f *SourceMapFilter) Write(p []byte) (n int, err error) { n2, err = f.Writer.Write(w) n += n2 + f.BytesWritten[f.currentSection] += n2 for { i := bytes.IndexByte(w, '\n') if i == -1 { @@ -294,3 +322,14 @@ func (f *SourceMapFilter) Write(p []byte) (n int, err error) { n += 5 } } + +// EnterSection marks a beginning of a new program section. +// +// All bytes written between entering and leaving a section will be attributed +// to the specified section in the artifact size report. The returned callback +// restores the previous section, allowing for sections to nest. +func (f *SourceMapFilter) EnterSection(s string) func() { + previousSection := f.currentSection + f.currentSection = s + return func() { f.currentSection = previousSection } +} diff --git a/go.mod b/go.mod index 2b93c685..b4298bfd 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,9 @@ module github.com/goplusjs/gopherjs go 1.13 require ( + github.com/dustin/go-humanize v1.0.0 github.com/fsnotify/fsnotify v1.4.7 - github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect + github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 github.com/kisielk/gotool v1.0.0 github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86 github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab diff --git a/go.sum b/go.sum index b47ee860..33a0af99 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/cpuguy83/go-md2man v1.0.8/go.mod h1:N6JayAiVKtlHSnuTCeuLSQVs75hb8q+dY github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-delve/delve v1.3.2 h1:K8VjV+Q2YnBYlPq0ctjrvc9h7h03wXszlszzfGW5Tog= diff --git a/tool.go b/tool.go index f7fe29c9..f0ddf534 100644 --- a/tool.go +++ b/tool.go @@ -82,6 +82,7 @@ func main() { compilerFlags.StringVar(&tags, "tags", "", "a list of build tags to consider satisfied during the build") compilerFlags.BoolVar(&options.MapToLocalDisk, "localmap", false, "use local paths for sourcemap") compilerFlags.BoolVarP(&options.Rebuild, "force", "a", false, "force rebuilding of packages that are already up-to-date") + compilerFlags.BoolVar(&options.AnalyzeSize, "analyze_size", false, "produce an Tree Map diagram visualizing each package's contribution to the output artifact size") flagWatch := pflag.NewFlagSet("", 0) flagWatch.BoolVarP(&options.Watch, "watch", "w", false, "watch for changes to the source files") From 5f6ecdf0ed360e566c6af600c242bf73643e3d9b Mon Sep 17 00:00:00 2001 From: Nevkontakte Date: Fri, 8 Jan 2021 13:46:06 +0000 Subject: [PATCH 2/2] Print artifact size analysis in the text form if -v option is passed. --- build/analysis/tree.go | 2 +- build/analysis/tree_test.go | 6 +++--- build/analysis/visualize.go | 11 ++++++++--- build/build.go | 7 ++++--- go.mod | 1 + go.sum | 4 ++++ 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/build/analysis/tree.go b/build/analysis/tree.go index 87e445fa..d9465ce7 100644 --- a/build/analysis/tree.go +++ b/build/analysis/tree.go @@ -218,7 +218,7 @@ func (n *treeMapNode) Describe() string { // String representation of the node and its effective children. func (n *treeMapNode) String() string { s := &strings.Builder{} - fmt.Fprintf(s, " %s %s\n", strings.Repeat(" *", n.depth), n.Describe()) + fmt.Fprintf(s, " %s %s\n", strings.Repeat(" |", n.depth), n.Describe()) for _, c := range n.effectiveChildren { s.WriteString(c.String()) } diff --git a/build/analysis/tree_test.go b/build/analysis/tree_test.go index 834e04ca..f9da11db 100644 --- a/build/analysis/tree_test.go +++ b/build/analysis/tree_test.go @@ -21,9 +21,9 @@ func TestTreeNodeOrganize(t *testing.T) { // shorter path to the tree root. want := "" + " main — 1.0 MiB own size, 1.1 MiB with children\n" + - " * fmt — 50 KiB own size, 51 KiB with children\n" + - " * * strconv — 1.0 KiB own size, 1.0 KiB with children\n" + - " * io — 10 KiB own size, 10 KiB with children\n" + " | fmt — 50 KiB own size, 51 KiB with children\n" + + " | | strconv — 1.0 KiB own size, 1.0 KiB with children\n" + + " | io — 10 KiB own size, 10 KiB with children\n" got := main.String() if diff := cmp.Diff(want, got); diff != "" { t.Errorf("main.organizeTree() produced unexpected structure (-want,+got):\n%s", diff) diff --git a/build/analysis/visualize.go b/build/analysis/visualize.go index 1d8328dd..3cb89316 100644 --- a/build/analysis/visualize.go +++ b/build/analysis/visualize.go @@ -10,9 +10,10 @@ import ( // Visualizer renders a TreeMap diagram representing the generated JS file and // how different packages contributed it its size. type Visualizer struct { - File *os.File - Main *compiler.Archive - Deps []*compiler.Archive + File *os.File + Main *compiler.Archive + Deps []*compiler.Archive + PrintStats bool } // Render a Tree Map diagram for the given package sizes. @@ -32,6 +33,10 @@ func (v *Visualizer) Render(stats map[string]int) error { return err } fmt.Fprintf(v.File, "\n") + + if v.PrintStats { + fmt.Println(tree) + } return nil } diff --git a/build/build.go b/build/build.go index 8b036ea0..0757e135 100644 --- a/build/build.go +++ b/build/build.go @@ -966,9 +966,10 @@ func (s *Session) WriteCommandPackage(archive *compiler.Archive, pkgObj string) } defer f.Close() visualizer := &analysis.Visualizer{ - Main: archive, - Deps: deps, - File: f, + Main: archive, + Deps: deps, + File: f, + PrintStats: s.options.Verbose && !s.options.Quiet, } defer func() { if err := visualizer.Render(sourceMapFilter.BytesWritten); err != nil { diff --git a/go.mod b/go.mod index b4298bfd..4facd229 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.13 require ( github.com/dustin/go-humanize v1.0.0 github.com/fsnotify/fsnotify v1.4.7 + github.com/google/go-cmp v0.5.4 github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 github.com/kisielk/gotool v1.0.0 github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86 diff --git a/go.sum b/go.sum index 33a0af99..808c57e9 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/go-delve/delve v1.3.2 h1:K8VjV+Q2YnBYlPq0ctjrvc9h7h03wXszlszzfGW5Tog= github.com/go-delve/delve v1.3.2/go.mod h1:LLw6qJfIsRK9WcwV2IRRqsdlgrqzOeuGrQOCOIhDpt8= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de h1:F7WD09S8QB4LrkEpka0dFPLSotH11HRpCsLIbIcJ7sU= github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0= @@ -121,6 +123,8 @@ golang.org/x/tools v0.0.0-20200131143746-097c1f2eed26/go.mod h1:TB2adYChydJhpapK golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=