Perhaps someone will write a less "link only" answer, but these (bulky) documents are central to the answer:
Some personal experience:
Sometimes a lot of emphasis is given to query optimization (which should be done) but the rendering part of the work - where the apex:xxxx tags execute - can also be very time consuming. Showing less content in your pages is one choice to address that but you'll have to convince your users of that sort of change. The apex:xxxx also contribute view state, and using raw HTML instead is sometimes worthwhile.
Because controller fields default to being included in the view state, you will probably find that making a lot more fields transient will help performance.
I spent a couple of days optimising two big pages that hadn't been optimised for a couple of years and it wasn't too hard to halve the page load time just based on the feedback that the Developer Console provided.
PS
To expand on the point about transient fields, this lazy loading approach is sometimes used in controllers:
public Contact[] contacts {
get {
if (contacts == null) {
contacts = [select FirstName, LastName, Birthdate, ... from Contact where ...];
}
return contacts;
}
private set;
}
It has the advantage that the query is done once the first time the field is referenced and further references (in other methods in the controller class and finally when the page is rendered) just use the cached data. But it has the disadvantage that all the data is included in the view state meaning it is sent to the client (as part of a large hidden field) and is sent back to the server and turned back into the collection when e.g. a form is submitted. That can be time consuming when the collection has many rows so it can be sometimes be worth changing to this transient pattern instead.